diff --git a/docs/labelbox/index.rst b/docs/labelbox/index.rst index 347abf6b4..8069b6b62 100644 --- a/docs/labelbox/index.rst +++ b/docs/labelbox/index.rst @@ -24,6 +24,9 @@ Labelbox Python SDK Documentation foundry-model identifiable identifiables + issue + issue-category + issue-position label label-score labeling-frontend diff --git a/docs/labelbox/issue-category.rst b/docs/labelbox/issue-category.rst new file mode 100644 index 000000000..bd9f96372 --- /dev/null +++ b/docs/labelbox/issue-category.rst @@ -0,0 +1,6 @@ +Issue Category +=============================================================================================== + +.. automodule:: labelbox.schema.issue_category + :members: + :show-inheritance: diff --git a/docs/labelbox/issue-position.rst b/docs/labelbox/issue-position.rst new file mode 100644 index 000000000..2dd59b448 --- /dev/null +++ b/docs/labelbox/issue-position.rst @@ -0,0 +1,6 @@ +Issue Position +=============================================================================================== + +.. automodule:: labelbox.schema.issue_position + :members: + :show-inheritance: diff --git a/docs/labelbox/issue.rst b/docs/labelbox/issue.rst new file mode 100644 index 000000000..8937a13d8 --- /dev/null +++ b/docs/labelbox/issue.rst @@ -0,0 +1,6 @@ +Issue +=============================================================================================== + +.. automodule:: labelbox.schema.issue + :members: + :show-inheritance: diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index 0979d1590..c194d135c 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -32,6 +32,16 @@ from labelbox.schema.identifiable import GlobalKey, UniqueId from labelbox.schema.identifiables import DataRowIds, GlobalKeys, UniqueIds from labelbox.schema.invite import Invite, InviteLimit +from labelbox.schema.issue import Comment, Issue, IssueStatus +from labelbox.schema.issue_category import IssueCategory +from labelbox.schema.issue_position import ( + ImageIssuePosition, + IssuePosition, + PdfIssuePosition, + TextIssuePosition, + VideoFrameRange, + VideoIssuePosition, +) from labelbox.schema.label import Label from labelbox.schema.label_score import LabelScore from labelbox.schema.labeling_frontend import ( diff --git a/libs/labelbox/src/labelbox/schema/issue.py b/libs/labelbox/src/labelbox/schema/issue.py new file mode 100644 index 000000000..af5da716d --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/issue.py @@ -0,0 +1,487 @@ +"""Issue and Comment models for the Labelbox Python SDK. + +Uses ``_CamelCaseMixin`` (Pydantic) instead of ``DbObject`` / ``Updateable`` +/ ``Deletable`` because the backend's GraphQL mutations use typed input objects +incompatible with the ORM's auto-generated mutations. +""" + +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any, List, Optional + +from pydantic import ConfigDict, PrivateAttr + +from labelbox.orm.model import Entity +from labelbox.schema.issue_category import IssueCategory +from labelbox.schema.issue_position import ( + IssuePosition, + _deserialize_position, +) +from labelbox.schema.user import User +from labelbox.utils import _CamelCaseMixin + +if TYPE_CHECKING: + from labelbox.schema.data_row import DataRow + from labelbox.schema.label import Label + + +class IssueStatus(str, Enum): + """Status of an issue.""" + + OPEN = "Open" + RESOLVED = "Resolved" + + +# --------------------------------------------------------------------------- +# GraphQL fragments +# --------------------------------------------------------------------------- + +_USER_FIELDS = ( + "id email nickname name picture isViewer isExternalUser createdAt updatedAt" +) + +_COMMENT_FIELDS = ( + """ + id + content + createdBy { %s } + createdAt + updatedAt +""" + % _USER_FIELDS +) + +_ISSUE_FIELDS = """ + id + friendlyId + labelId + dataRowId + categoryId + content + position + status + createdBy { %s } + resolvedBy { %s } + createdAt + updatedAt + resolvedAt + contentUpdatedAt + latestReplyAt +""" % (_USER_FIELDS, _USER_FIELDS) + + +# --------------------------------------------------------------------------- +# Helper: build a User DbObject from a raw dict +# --------------------------------------------------------------------------- + + +def _build_user(client: Any, raw: Optional[dict]) -> Optional[User]: + """Construct a :class:`User` DbObject from a GraphQL response fragment. + + Returns ``None`` when *raw* is ``None``. + """ + if raw is None: + return None + return User(client, raw) + + +# --------------------------------------------------------------------------- +# Comment +# --------------------------------------------------------------------------- + + +class Comment(_CamelCaseMixin): + """A comment attached to an :class:`Issue`. + + Attributes: + id: Unique identifier. + content: Comment body text. + created_by: The :class:`~labelbox.schema.user.User` who authored the + comment. + created_at: Creation timestamp. + updated_at: Last-modification timestamp. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + populate_by_name=True, + ) + + id: str + content: str + created_by: Any # User DbObject + created_at: datetime + updated_at: datetime + _client: Any = PrivateAttr(default=None) + + def __repr__(self) -> str: + return "" % self.id + + def update(self, content: str) -> "Comment": + """Update this comment's content. + + Args: + content: New body text. + + Returns: + Updated :class:`Comment` instance. + """ + query_str = ( + """mutation UpdateCommentPyApi( + $where: WhereUniqueIdInput!, + $data: UpdateCommentInput! + ) { + updateComment(where: $where, data: $data) { %s } + }""" + % _COMMENT_FIELDS + ) + + result = self._client.execute( + query_str, + { + "where": {"id": self.id}, + "data": {"content": content}, + }, + experimental=True, + ) + return _parse_comment(self._client, result["updateComment"]) + + def delete(self) -> bool: + """Delete this comment. + + Returns: + ``True`` when the deletion succeeds. + """ + query_str = """mutation DeleteCommentPyApi( + $where: WhereUniqueIdInput! + ) { + deleteComment(where: $where) + }""" + + self._client.execute( + query_str, {"where": {"id": self.id}}, experimental=True + ) + return True + + +# --------------------------------------------------------------------------- +# Issue +# --------------------------------------------------------------------------- + + +class Issue(_CamelCaseMixin): + """An issue pinned to a data row within a project. + + Attributes: + id: Unique identifier. + friendly_id: Human-readable short identifier. + label_id: Associated label ID (may be ``None``). + data_row_id: Associated data-row ID (may be ``None`` for legacy + issues). + category_id: Associated issue-category ID (may be ``None``). + content: Issue body text. + position: Typed position model or ``None``. + status: :class:`IssueStatus` (``OPEN`` / ``RESOLVED``). + created_by: The :class:`~labelbox.schema.user.User` who created the + issue. + resolved_by: The :class:`~labelbox.schema.user.User` who resolved it, + or ``None``. + created_at: Creation timestamp. + updated_at: Last-modification timestamp. + resolved_at: Resolution timestamp, or ``None``. + content_updated_at: Timestamp of last content edit, or ``None``. + latest_reply_at: Timestamp of the most recent comment, or ``None``. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + populate_by_name=True, + ) + + id: str + friendly_id: str + label_id: Optional[str] = None + data_row_id: Optional[str] = None + category_id: Optional[str] = None + content: str + position: Optional[Any] = None # IssuePosition (typed at runtime) + status: IssueStatus + created_by: Any # User DbObject + resolved_by: Optional[Any] = None # User DbObject or None + created_at: datetime + updated_at: datetime + resolved_at: Optional[datetime] = None + content_updated_at: Optional[datetime] = None + latest_reply_at: Optional[datetime] = None + _project_id: Optional[str] = PrivateAttr(default=None) + _client: Any = PrivateAttr(default=None) + + def __repr__(self) -> str: + return "" % self.id + + # ------------------------------------------------------------------ + # Methods that fetch related objects (each makes an API call) + # ------------------------------------------------------------------ + + def comments(self) -> List[Comment]: + """Fetch all comments for this issue. + + Returns: + List of :class:`Comment` instances. + """ + query_str = ( + """query GetIssueCommentsPyApi($where: WhereUniqueIdInput!) { + issue(where: $where) { + comments { %s } + } + }""" + % _COMMENT_FIELDS + ) + + result = self._client.execute( + query_str, {"where": {"id": self.id}}, experimental=True + ) + raw_comments = result.get("issue", {}).get("comments", []) + return [_parse_comment(self._client, c) for c in raw_comments] + + def data_row(self) -> Optional["DataRow"]: + """Fetch the associated :class:`~labelbox.schema.data_row.DataRow`. + + Returns: + The data row, or ``None`` if :attr:`data_row_id` is not set. + """ + if self.data_row_id is None: + return None + return self._client.get_data_row(self.data_row_id) + + def category(self) -> Optional[IssueCategory]: + """Fetch the associated :class:`IssueCategory`. + + Requires :attr:`project_id` to be set (automatically populated + when the issue is obtained via :class:`Project` methods). + + Returns: + The category, or ``None`` if :attr:`category_id` is not set + or the project context is unavailable. + """ + if self.category_id is None: + return None + if self._project_id is None: + return None + + query_str = """query GetIssueCategoriesPyApi($projectId: ID!) { + project(where: {id: $projectId}) { + issueCategories { id name description } + } + }""" + + result = self._client.execute( + query_str, {"projectId": self._project_id} + ) + raw_list = result.get("project", {}).get("issueCategories", []) + for raw in raw_list: + if raw["id"] == self.category_id: + cat = IssueCategory( + id=raw["id"], + name=raw["name"], + description=raw["description"], + ) + cat._client = self._client + return cat + return None + + def label(self) -> Optional["Label"]: + """Fetch the associated :class:`~labelbox.schema.label.Label`. + + Returns: + The label, or ``None`` if :attr:`label_id` is not set. + """ + if self.label_id is None: + return None + return self._client._get_single(Entity.Label, self.label_id) + + # ------------------------------------------------------------------ + # Mutation methods + # ------------------------------------------------------------------ + + def update( + self, + content: Optional[str] = None, + category_id: Optional[str] = None, + position: Optional[IssuePosition] = None, + label_id: Optional[str] = None, + ) -> "Issue": + """Update this issue. + + Only the provided fields are modified; pass ``None`` to leave a + field unchanged. + + Args: + content: New body text. + category_id: New category ID. + position: New position model. + label_id: New label ID. + + Returns: + Updated :class:`Issue` instance. + """ + data: dict = {} + if content is not None: + data["content"] = content + if category_id is not None: + data["categoryId"] = category_id + if position is not None: + data["position"] = position.to_dict() + if label_id is not None: + data["labelId"] = label_id + + if not data: + return self + + query_str = ( + """mutation UpdateIssuePyApi( + $where: WhereUniqueIdInput!, + $data: UpdateIssueInput! + ) { + updateIssue(where: $where, data: $data) { %s } + }""" + % _ISSUE_FIELDS + ) + + result = self._client.execute( + query_str, + {"where": {"id": self.id}, "data": data}, + experimental=True, + ) + return _parse_issue( + self._client, result["updateIssue"], project_id=self._project_id + ) + + def delete(self) -> bool: + """Delete this issue. + + Returns: + ``True`` when the deletion succeeds. + """ + query_str = """mutation DeleteIssuePyApi($data: DeleteIssueInput!) { + deleteIssue(data: $data) + }""" + + self._client.execute( + query_str, + {"data": {"issueIds": [self.id]}}, + experimental=True, + ) + return True + + def resolve(self) -> "Issue": + """Resolve this issue. + + Returns: + Updated :class:`Issue` with ``status == IssueStatus.RESOLVED``. + """ + query_str = ( + """mutation ResolveIssuePyApi($where: WhereUniqueIdInput!) { + resolveIssue(where: $where) { %s } + }""" + % _ISSUE_FIELDS + ) + + result = self._client.execute( + query_str, {"where": {"id": self.id}}, experimental=True + ) + return _parse_issue( + self._client, result["resolveIssue"], project_id=self._project_id + ) + + def reopen(self) -> "Issue": + """Re-open this issue. + + Returns: + Updated :class:`Issue` with ``status == IssueStatus.OPEN``. + """ + query_str = ( + """mutation OpenIssuePyApi($where: WhereUniqueIdInput!) { + openIssue(where: $where) { %s } + }""" + % _ISSUE_FIELDS + ) + + result = self._client.execute( + query_str, {"where": {"id": self.id}}, experimental=True + ) + return _parse_issue( + self._client, result["openIssue"], project_id=self._project_id + ) + + def create_comment(self, content: str) -> Comment: + """Create a new comment on this issue. + + Args: + content: Comment body text. + + Returns: + The newly created :class:`Comment`. + """ + query_str = ( + """mutation CreateCommentPyApi($data: CreateCommentInput!) { + createComment(data: $data) { %s } + }""" + % _COMMENT_FIELDS + ) + + result = self._client.execute( + query_str, + {"data": {"content": content, "issueId": self.id}}, + experimental=True, + ) + return _parse_comment(self._client, result["createComment"]) + + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + + +def _parse_comment(client: Any, data: dict) -> Comment: + """Build a :class:`Comment` from a raw GraphQL response dict.""" + created_by = _build_user(client, data.get("createdBy")) + comment = Comment( + id=data["id"], + content=data["content"], + created_by=created_by, + created_at=data["createdAt"], + updated_at=data["updatedAt"], + ) + comment._client = client + return comment + + +def _parse_issue( + client: Any, data: dict, project_id: Optional[str] = None +) -> Issue: + """Build an :class:`Issue` from a raw GraphQL response dict.""" + created_by = _build_user(client, data.get("createdBy")) + resolved_by = _build_user(client, data.get("resolvedBy")) + position = _deserialize_position(data.get("position")) + + issue = Issue( + id=data["id"], + friendly_id=data["friendlyId"], + label_id=data.get("labelId"), + data_row_id=data.get("dataRowId"), + category_id=data.get("categoryId"), + content=data["content"], + position=position, + status=IssueStatus(data["status"]), + created_by=created_by, + resolved_by=resolved_by, + created_at=data["createdAt"], + updated_at=data["updatedAt"], + resolved_at=data.get("resolvedAt"), + content_updated_at=data.get("contentUpdatedAt"), + latest_reply_at=data.get("latestReplyAt"), + ) + issue._client = client + issue._project_id = project_id + return issue diff --git a/libs/labelbox/src/labelbox/schema/issue_category.py b/libs/labelbox/src/labelbox/schema/issue_category.py new file mode 100644 index 000000000..d2bb601a0 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/issue_category.py @@ -0,0 +1,89 @@ +"""IssueCategory model for the Labelbox Python SDK. + +Uses ``_CamelCaseMixin`` (Pydantic) instead of ``DbObject`` / ``Updateable`` +/ ``Deletable`` because the backend's GraphQL mutations use typed input objects +incompatible with the ORM's auto-generated mutations. +""" + +from typing import Any + +from pydantic import ConfigDict, PrivateAttr + +from labelbox.utils import _CamelCaseMixin + + +class IssueCategory(_CamelCaseMixin): + """A category that can be assigned to issues within a project. + + Attributes: + id: Unique identifier. + name: Display name. + description: Human-readable description. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + populate_by_name=True, + ) + + id: str + name: str + description: str + _client: Any = PrivateAttr(default=None) + + def __repr__(self) -> str: + return "" % self.id + + def update(self, name: str, description: str) -> "IssueCategory": + """Update this issue category. + + Args: + name: New name for the category. + description: New description for the category. + + Returns: + Updated :class:`IssueCategory` instance. + """ + query_str = """mutation EditIssueCategoryPyApi( + $where: WhereUniqueIdInput!, + $data: EditIssueCategoryInput! + ) { + editIssueCategory(where: $where, data: $data) { + id name description + } + }""" + + result = self._client.execute( + query_str, + { + "where": {"id": self.id}, + "data": {"name": name, "description": description}, + }, + experimental=True, + ) + + data = result["editIssueCategory"] + cat = IssueCategory( + id=data["id"], + name=data["name"], + description=data["description"], + ) + cat._client = self._client + return cat + + def delete(self) -> bool: + """Delete this issue category. + + Returns: + ``True`` when the deletion succeeds. + """ + query_str = """mutation DeleteIssueCategoryPyApi( + $where: WhereUniqueIdInput! + ) { + deleteIssueCategory(where: $where) + }""" + + self._client.execute( + query_str, {"where": {"id": self.id}}, experimental=True + ) + return True diff --git a/libs/labelbox/src/labelbox/schema/issue_position.py b/libs/labelbox/src/labelbox/schema/issue_position.py new file mode 100644 index 000000000..0c798eb43 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/issue_position.py @@ -0,0 +1,260 @@ +"""Position models for issues, varying by media type. + +Each position model serializes to a GeoJSON-compatible dict for the +``position: Json`` GraphQL field. +""" + +import json +import logging +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, field_validator + +from labelbox.schema.media_type import MediaType + +logger = logging.getLogger(__name__) + + +class ImageIssuePosition(BaseModel): + """Pin position on an image asset. + + Attributes: + x: Horizontal pixel coordinate. + y: Vertical pixel coordinate. + """ + + x: int + y: int + + def to_dict(self) -> dict: + return {"type": "Point", "coordinates": [self.x, self.y]} + + +class PdfIssuePosition(BaseModel): + """Pin position on a PDF page. + + Coordinates are expressed as percentages (0.0 – 1.0) of the page + dimensions, matching the backend ``PERCENT`` unit. + + Attributes: + x: Horizontal position as a fraction of page width (0.0 – 1.0). + y: Vertical position as a fraction of page height (0.0 – 1.0). + page: Zero-based page index. + """ + + x: float + y: float + page: int + + @field_validator("x", "y") + @classmethod + def _check_range(cls, v: float) -> float: + if not (0.0 <= v <= 1.0): + raise ValueError( + "PDF coordinates must be between 0.0 and 1.0 (percentage). " + f"Got {v}." + ) + return v + + def to_dict(self) -> dict: + return { + "type": "Point", + "coordinates": [self.x, self.y], + "page": self.page, + "unit": "PERCENT", + } + + +class TextIssuePosition(BaseModel): + """Character range within a text block. + + Attributes: + text_block_id: Identifier of the text block. + start_char_index: Start character index (inclusive). + end_char_index: End character index (exclusive). + """ + + text_block_id: str + start_char_index: int + end_char_index: int + + def to_dict(self) -> dict: + return { + "textBlockId": self.text_block_id, + "startCharIndex": self.start_char_index, + "endCharIndex": self.end_char_index, + } + + +class VideoFrameRange(BaseModel): + """A contiguous frame range with optional moving coordinates. + + For a single frame, set ``start == end``. When ``start == end`` the + ``end_x`` / ``end_y`` fields are ignored during serialization. + + Attributes: + start: Start frame number. + end: End frame number (equal to *start* for a single frame). + x: Horizontal pixel coordinate at *start*. + y: Vertical pixel coordinate at *start*. + end_x: Horizontal pixel coordinate at *end* (moving pin). Ignored + when ``start == end``. + end_y: Vertical pixel coordinate at *end* (moving pin). Ignored + when ``start == end``. + """ + + start: int + end: int + x: int + y: int + end_x: Optional[int] = None + end_y: Optional[int] = None + + +class VideoIssuePosition(BaseModel): + """Pin position(s) on a video asset. + + Supports single frames, contiguous ranges, and multiple separated + ranges (with optional moving coordinates). + + Attributes: + frames: One or more :class:`VideoFrameRange` entries. + """ + + frames: List[VideoFrameRange] + + def to_dict(self) -> dict: + """Serialize to KeyframesGeoJSONPoint format.""" + keyframes: list = [] + for fr in self.frames: + start_entry = { + "frame": fr.start, + "value": { + "type": "Point", + "coordinates": [fr.x, fr.y], + }, + } + keyframes.append(start_entry) + # Only emit a separate end keyframe when the range spans + # multiple frames. + if fr.end != fr.start: + end_entry = { + "frame": fr.end, + "value": { + "type": "Point", + "coordinates": [ + fr.end_x if fr.end_x is not None else fr.x, + fr.end_y if fr.end_y is not None else fr.y, + ], + }, + } + keyframes.append(end_entry) + return {"type": "KeyframesGeoJSONPoint", "keyframes": keyframes} + + +IssuePosition = Union[ + ImageIssuePosition, + PdfIssuePosition, + TextIssuePosition, + VideoIssuePosition, +] + +MEDIA_TYPE_POSITION_MAP: Dict[MediaType, type] = { + MediaType.Image: ImageIssuePosition, + MediaType.Video: VideoIssuePosition, + MediaType.Text: TextIssuePosition, + MediaType.Document: PdfIssuePosition, + MediaType.Pdf: PdfIssuePosition, +} + + +def _deserialize_position( + raw: Optional[Union[str, dict]], +) -> Optional[IssuePosition]: + """Convert a raw position value from GraphQL into a typed model. + + Returns ``None`` (with a warning) when the structure is unrecognized, + ensuring forward-compatibility with new media types. + """ + if raw is None: + return None + + data: Any # Use Any for safer checking after json.loads + if isinstance(raw, str): + try: + data = json.loads(raw) + if data is None: + return None + except (json.JSONDecodeError, TypeError): + return None + else: + data = raw + + if not isinstance(data, dict): + return None + + try: + # PDF – has "page" key + if "page" in data: + coords = data.get("coordinates", [0.0, 0.0]) + return PdfIssuePosition(x=coords[0], y=coords[1], page=data["page"]) + + # Text – has "textBlockId" key + if "textBlockId" in data: + return TextIssuePosition( + text_block_id=data["textBlockId"], + start_char_index=data["startCharIndex"], + end_char_index=data["endCharIndex"], + ) + + # Video – KeyframesGeoJSONPoint + if data.get("type") == "KeyframesGeoJSONPoint": + frames: List[VideoFrameRange] = [] + kf_list = data.get("keyframes", []) + i = 0 + while i < len(kf_list): + kf = kf_list[i] + start_frame = kf["frame"] + start_coords = kf["value"]["coordinates"] + # Look ahead for an end keyframe + if i + 1 < len(kf_list): + next_kf = kf_list[i + 1] + next_coords = next_kf["value"]["coordinates"] + end_frame = next_kf["frame"] + if end_frame != start_frame: + frames.append( + VideoFrameRange( + start=start_frame, + end=end_frame, + x=int(start_coords[0]), + y=int(start_coords[1]), + end_x=int(next_coords[0]), + end_y=int(next_coords[1]), + ) + ) + i += 2 + continue + # Single frame or last entry + frames.append( + VideoFrameRange( + start=start_frame, + end=start_frame, + x=int(start_coords[0]), + y=int(start_coords[1]), + ) + ) + i += 1 + return VideoIssuePosition(frames=frames) + + # Image – plain GeoJSON Point + if data.get("type") == "Point": + coords = data.get("coordinates", [0, 0]) + return ImageIssuePosition(x=int(coords[0]), y=int(coords[1])) + except (KeyError, IndexError, TypeError, ValueError) as exc: + logger.warning( + "Failed to deserialize issue position: %s (%s)", data, exc + ) + return None + + logger.warning("Unrecognized issue position structure: %s", data) + return None diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index 206d3c43d..b75b9c55d 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -59,6 +59,18 @@ ProjectOverview, ProjectOverviewDetailed, ) +from labelbox.schema.issue import ( + Issue, + IssueStatus, + _ISSUE_FIELDS, + _parse_issue, +) +from labelbox.schema.issue_category import IssueCategory +from labelbox.schema.issue_position import ( + MEDIA_TYPE_POSITION_MAP, + IssuePosition, +) +from labelbox.schema.label import Label from labelbox.schema.workflow import ProjectWorkflow from labelbox.schema.resource_tag import ResourceTag from labelbox.schema.task import Task @@ -1819,6 +1831,302 @@ def clone_workflow_from(self, source_project_id: str) -> "ProjectWorkflow": target_project_id=self.uid, ) + # ------------------------------------------------------------------ + # Issue management + # ------------------------------------------------------------------ + + def create_issue( + self, + content: str, + data_row_id: Union[str, "DataRow"], + label_id: Optional[Union[str, "Label"]] = None, + category_id: Optional[Union[str, "IssueCategory"]] = None, + position: Optional[IssuePosition] = None, + ) -> Issue: + """Create a new issue in this project. + + Args: + content: Issue body text. + data_row_id: The data row to attach the issue to. Accepts a + string ID or a :class:`~labelbox.schema.data_row.DataRow` + instance. + label_id: Optional label to associate. Accepts a string ID or + a :class:`~labelbox.schema.label.Label` instance. Strongly + recommended: the backend only returns issues that have a + ``label_id`` from :meth:`get_issues`, so issues created + without one will not appear in paginated queries. + category_id: Optional issue category. Accepts a string ID or + an :class:`~labelbox.schema.issue_category.IssueCategory` + instance. + position: Optional typed position (e.g. + :class:`~labelbox.schema.issue_position.ImageIssuePosition`). + Must match the project's media type. + + Returns: + The newly created :class:`Issue`. + + Raises: + TypeError: If *position* does not match the project's media + type. + """ + # Resolve DbObject instances to string IDs + resolved_data_row_id = ( + data_row_id.uid if hasattr(data_row_id, "uid") else str(data_row_id) + ) + resolved_label_id: Optional[str] = None + if label_id is not None: + resolved_label_id = ( + label_id.uid if hasattr(label_id, "uid") else str(label_id) + ) + resolved_category_id: Optional[str] = None + if category_id is not None: + resolved_category_id = ( + category_id.uid + if hasattr(category_id, "uid") + else str(category_id) + ) + + # Validate position type against project media type + if position is not None and self.media_type is not None: + expected_cls = MEDIA_TYPE_POSITION_MAP.get(self.media_type) + if expected_cls is not None and not isinstance( + position, expected_cls + ): + raise TypeError( + f"Position type {type(position).__name__} is not valid " + f"for media type {self.media_type.name}. " + f"Expected {expected_cls.__name__}." + ) + + mutation_data: Dict[str, Any] = { + "content": content, + "projectId": self.uid, + "dataRowId": resolved_data_row_id, + "type": "Issue", + } + if resolved_label_id is not None: + mutation_data["labelId"] = resolved_label_id + if resolved_category_id is not None: + mutation_data["categoryId"] = resolved_category_id + if position is not None: + mutation_data["position"] = position.to_dict() + + query_str = ( + """mutation CreateIssuePyApi($data: CreateIssueInput!) { + createIssue(data: $data) { %s } + }""" + % _ISSUE_FIELDS + ) + + result = self.client.execute( + query_str, {"data": mutation_data}, experimental=True + ) + issue = _parse_issue( + self.client, result["createIssue"], project_id=self.uid + ) + + # The createIssue mutation may not return dataRowId / labelId / + # categoryId in its response. Since we know the values from the + # input, patch them onto the returned object so callers don't + # have to re-fetch. + if issue.data_row_id is None and resolved_data_row_id is not None: + issue.data_row_id = resolved_data_row_id + if issue.label_id is None and resolved_label_id is not None: + issue.label_id = resolved_label_id + if issue.category_id is None and resolved_category_id is not None: + issue.category_id = resolved_category_id + + return issue + + def get_issues( + self, + status: Optional[IssueStatus] = None, + data_row_id: Optional[str] = None, + category_id: Optional[str] = None, + created_by_ids: Optional[List[str]] = None, + content: Optional[str] = None, + ) -> PaginatedCollection: + """Fetch issues for this project with optional filters. + + Uses cursor-based pagination (``after`` / ``first``) as defined + by the ``IssueConnection`` return type. Returns a lazy + :class:`~labelbox.pagination.PaginatedCollection` that pages + transparently during iteration. + + .. note:: + The backend only returns issues that have a ``label_id``. + Issues created without a label will not appear in the + results. Use :meth:`get_issue` (by ID) or + :meth:`export_issues` to retrieve them. + + Args: + status: Filter by issue status. + data_row_id: Filter by data-row ID. + category_id: Filter by category ID. + created_by_ids: Filter by creator user IDs. + content: Full-text search on issue content. + + Returns: + A :class:`PaginatedCollection` of :class:`Issue` instances. + """ + # Build the where filter to match the backend ProjectIssueInput + where: Dict[str, Any] = {"type": "Issue"} + if status is not None: + where["status"] = status.value # "Open" or "Resolved" + if data_row_id is not None: + where["dataRow"] = {"id": data_row_id} + if category_id is not None: + where["categoryId"] = category_id + if created_by_ids is not None: + where["createdByIds"] = created_by_ids + if content is not None: + where["content"] = content + + query_str = ( + """query GetProjectIssuesPyApi( + $projectId: ID!, $where: ProjectIssueInput, + $from: ID, $first: PageSize + ) { + project(where: {id: $projectId}) { + issues(where: $where, after: $from, first: $first) { + nodes { %s } + nextCursor + } + } + }""" + % _ISSUE_FIELDS + ) + + project_id = self.uid + params: Dict[str, Any] = { + "projectId": self.uid, + "where": where, + } + + return PaginatedCollection( + client=self.client, + query=query_str, + params=params, # type: ignore[arg-type] + dereferencing=["project", "issues", "nodes"], + obj_class=lambda client, data: _parse_issue( + client, data, project_id=project_id + ), + cursor_path=["project", "issues", "nextCursor"], + experimental=True, + ) + + def get_issue(self, issue_id: str) -> Issue: + """Fetch a single issue by ID. + + Args: + issue_id: The issue's unique identifier. + + Returns: + An :class:`Issue` instance. + """ + query_str = ( + """query GetIssuePyApi($where: WhereUniqueIdInput!) { + issue(where: $where) { %s } + }""" + % _ISSUE_FIELDS + ) + + result = self.client.execute( + query_str, {"where": {"id": issue_id}}, experimental=True + ) + return _parse_issue(self.client, result["issue"], project_id=self.uid) + + def delete_issues(self, issue_ids: List[str]) -> bool: + """Delete one or more issues in bulk. + + The backend enforces creator-only authorization: the call will + fail if any of the listed issues belong to a different user. + Non-existent IDs are silently ignored. + + Args: + issue_ids: List of issue IDs to delete. + + Returns: + ``True`` when the mutation succeeds. + """ + query_str = """mutation DeleteIssuePyApi($data: DeleteIssueInput!) { + deleteIssue(data: $data) + }""" + + self.client.execute( + query_str, + {"data": {"issueIds": issue_ids}}, + experimental=True, + ) + return True + + # ------------------------------------------------------------------ + # Issue category management + # ------------------------------------------------------------------ + + def create_issue_category( + self, name: str, description: str + ) -> IssueCategory: + """Create a new issue category in this project. + + Args: + name: Category display name. + description: Human-readable description. + + Returns: + The newly created :class:`IssueCategory`. + """ + query_str = """mutation CreateIssueCategoryPyApi( + $data: CreateIssueCategoryInput! + ) { + createIssueCategory(data: $data) { id name description } + }""" + + result = self.client.execute( + query_str, + { + "data": { + "projectId": self.uid, + "name": name, + "description": description, + } + }, + experimental=True, + ) + data = result["createIssueCategory"] + cat = IssueCategory( + id=data["id"], + name=data["name"], + description=data["description"], + ) + cat._client = self.client + return cat + + def get_issue_categories(self) -> List[IssueCategory]: + """Fetch all issue categories for this project. + + Returns: + List of :class:`IssueCategory` instances. + """ + query_str = """query GetIssueCategoriesPyApi($projectId: ID!) { + project(where: {id: $projectId}) { + issueCategories { id name description } + } + }""" + + result = self.client.execute(query_str, {"projectId": self.uid}) + raw_list = result.get("project", {}).get("issueCategories", []) + categories = [] + for c in raw_list: + cat = IssueCategory( + id=c["id"], + name=c["name"], + description=c["description"], + ) + cat._client = self.client + categories.append(cat) + return categories + class ProjectMember(DbObject): user = Relationship.ToOne("User", cache=True) diff --git a/libs/labelbox/tests/integration/test_issue_management.py b/libs/labelbox/tests/integration/test_issue_management.py new file mode 100644 index 000000000..e0ee6913c --- /dev/null +++ b/libs/labelbox/tests/integration/test_issue_management.py @@ -0,0 +1,329 @@ +"""Integration tests for issue management (Issues, Comments, Issue Categories). + +Uses a single Image project with one data row to test the full CRUD +lifecycle, keeping setup cost minimal. +""" + +import time + +from labelbox import Project +from labelbox.schema.issue import Issue, IssueStatus, Comment +from labelbox.schema.issue_category import IssueCategory +from labelbox.schema.issue_position import ImageIssuePosition + + +# --------------------------------------------------------------------------- +# Issue Category CRUD +# --------------------------------------------------------------------------- + + +def test_create_issue_category(project: Project): + category = project.create_issue_category( + name="Quality", description="Quality-related issues" + ) + assert isinstance(category, IssueCategory) + assert category.id is not None + assert category.name == "Quality" + assert category.description == "Quality-related issues" + + # Cleanup + category.delete() + + +def test_get_issue_categories(project: Project): + cat1 = project.create_issue_category( + name="Cat A", description="First category" + ) + cat2 = project.create_issue_category( + name="Cat B", description="Second category" + ) + + categories = project.get_issue_categories() + cat_ids = {c.id for c in categories} + assert cat1.id in cat_ids + assert cat2.id in cat_ids + + # Cleanup + cat1.delete() + cat2.delete() + + +def test_update_issue_category(project: Project): + category = project.create_issue_category( + name="Original", description="Original description" + ) + updated = category.update(name="Renamed", description="New description") + assert updated.name == "Renamed" + assert updated.description == "New description" + + # Cleanup + updated.delete() + + +def test_delete_issue_category(project: Project): + category = project.create_issue_category( + name="ToDelete", description="Will be deleted" + ) + assert category.delete() is True + + +# --------------------------------------------------------------------------- +# Issue CRUD +# --------------------------------------------------------------------------- + + +def test_create_issue(project: Project, data_row): + issue = project.create_issue( + content="Something is wrong here", + data_row_id=data_row.uid, + ) + assert isinstance(issue, Issue) + assert issue.id is not None + assert issue.content == "Something is wrong here" + assert issue.status == IssueStatus.OPEN + assert issue.data_row_id == data_row.uid + assert issue.created_by is not None + + # Cleanup + issue.delete() + + +def test_create_issue_with_position(project: Project, data_row): + position = ImageIssuePosition(x=100, y=200) + issue = project.create_issue( + content="Pin on image", + data_row_id=data_row.uid, + position=position, + ) + assert issue.position is not None + + # Cleanup + issue.delete() + + +def test_create_issue_with_category(project: Project, data_row): + category = project.create_issue_category( + name="Test Category", description="For testing" + ) + issue = project.create_issue( + content="Categorized issue", + data_row_id=data_row.uid, + category_id=category.id, + ) + assert issue.category_id == category.id + + # Verify lazy-loaded category + fetched_cat = issue.category() + assert fetched_cat is not None + assert fetched_cat.id == category.id + + # Cleanup + issue.delete() + category.delete() + + +def test_get_issue(project: Project, data_row): + created = project.create_issue( + content="Fetch me", + data_row_id=data_row.uid, + ) + fetched = project.get_issue(created.id) + assert fetched.id == created.id + assert fetched.content == "Fetch me" + + # Cleanup + created.delete() + + +def test_get_issues(configured_project_with_label): + # get_issues() only returns issues that have a label_id (backend + # filters out labelId IS NULL), so we must attach a label. + project, _dataset, data_row, label = configured_project_with_label + + issue1 = project.create_issue( + content="First issue", + data_row_id=data_row.uid, + label_id=label.uid, + ) + issue2 = project.create_issue( + content="Second issue", + data_row_id=data_row.uid, + label_id=label.uid, + ) + + # Allow eventual consistency in the backend index + for _ in range(5): + issues = list(project.get_issues()) + issue_ids = {i.id for i in issues} + if issue1.id in issue_ids and issue2.id in issue_ids: + break + time.sleep(2) + + assert issue1.id in issue_ids + assert issue2.id in issue_ids + + # Cleanup + project.delete_issues([issue1.id, issue2.id]) + + +def test_get_issues_with_status_filter(configured_project_with_label): + # get_issues() only returns issues that have a label_id (backend + # filters out labelId IS NULL), so we must attach a label. + project, _dataset, data_row, label = configured_project_with_label + + issue = project.create_issue( + content="Filter test", + data_row_id=data_row.uid, + label_id=label.uid, + ) + + # Allow eventual consistency in the backend index + for _ in range(5): + open_issues = list(project.get_issues(status=IssueStatus.OPEN)) + if any(i.id == issue.id for i in open_issues): + break + time.sleep(2) + + assert any(i.id == issue.id for i in open_issues) + + # Give the backend a moment to ensure the index is consistent + # for the next query + time.sleep(2) + + resolved_issues = list(project.get_issues(status=IssueStatus.RESOLVED)) + assert not any(i.id == issue.id for i in resolved_issues) + + # Cleanup + issue.delete() + + +def test_update_issue(project: Project, data_row): + issue = project.create_issue( + content="Original content", + data_row_id=data_row.uid, + ) + updated = issue.update(content="Updated content") + assert updated.content == "Updated content" + + # Cleanup + updated.delete() + + +def test_resolve_and_reopen_issue(project: Project, data_row): + issue = project.create_issue( + content="Resolve me", + data_row_id=data_row.uid, + ) + resolved = issue.resolve() + assert resolved.status == IssueStatus.RESOLVED + assert resolved.resolved_by is not None + + reopened = resolved.reopen() + assert reopened.status == IssueStatus.OPEN + + # Cleanup + reopened.delete() + + +def test_delete_issue(project: Project, data_row): + issue = project.create_issue( + content="Delete me", + data_row_id=data_row.uid, + ) + assert issue.delete() is True + + +def test_delete_issues_bulk(project: Project, data_row): + issue1 = project.create_issue( + content="Bulk delete 1", + data_row_id=data_row.uid, + ) + issue2 = project.create_issue( + content="Bulk delete 2", + data_row_id=data_row.uid, + ) + assert project.delete_issues([issue1.id, issue2.id]) is True + + +# --------------------------------------------------------------------------- +# Issue accessor methods +# --------------------------------------------------------------------------- + + +def test_issue_data_row(project: Project, data_row): + issue = project.create_issue( + content="Data row test", + data_row_id=data_row.uid, + ) + fetched_dr = issue.data_row() + assert fetched_dr is not None + assert fetched_dr.uid == data_row.uid + + # Cleanup + issue.delete() + + +# --------------------------------------------------------------------------- +# Comment CRUD +# --------------------------------------------------------------------------- + + +def test_create_comment(project: Project, data_row): + issue = project.create_issue( + content="Comment test", + data_row_id=data_row.uid, + ) + comment = issue.create_comment(content="This is a comment") + assert isinstance(comment, Comment) + assert comment.id is not None + assert comment.content == "This is a comment" + assert comment.created_by is not None + + # Cleanup + issue.delete() + + +def test_get_comments(project: Project, data_row): + issue = project.create_issue( + content="Multi-comment test", + data_row_id=data_row.uid, + ) + comment1 = issue.create_comment(content="Comment 1") + comment2 = issue.create_comment(content="Comment 2") + + comments = issue.comments() + comment_ids = {c.id for c in comments} + assert comment1.id in comment_ids + assert comment2.id in comment_ids + + # Cleanup + issue.delete() + + +def test_update_comment(project: Project, data_row): + issue = project.create_issue( + content="Update comment test", + data_row_id=data_row.uid, + ) + comment = issue.create_comment(content="Original comment") + updated = comment.update(content="Revised comment") + assert updated.content == "Revised comment" + + # Cleanup + issue.delete() + + +def test_delete_comment(project: Project, data_row): + issue = project.create_issue( + content="Delete comment test", + data_row_id=data_row.uid, + ) + comment = issue.create_comment(content="Will be deleted") + assert comment.delete() is True + + # Verify comment is gone + remaining = issue.comments() + assert not any(c.id == comment.id for c in remaining) + + # Cleanup + issue.delete() diff --git a/libs/labelbox/tests/unit/schema/test_issue.py b/libs/labelbox/tests/unit/schema/test_issue.py new file mode 100644 index 000000000..47cdc772f --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_issue.py @@ -0,0 +1,293 @@ +from unittest.mock import MagicMock + +from labelbox.schema.issue import ( + Comment, + IssueStatus, + _parse_comment, + _parse_issue, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOW = "2025-01-15T10:00:00.000Z" + +_USER_RAW = { + "id": "user-1", + "email": "alice@example.com", + "nickname": "alice", + "name": "Alice", + "picture": "", + "isViewer": False, + "isExternalUser": False, + "createdAt": _NOW, + "updatedAt": _NOW, +} + +_COMMENT_RAW = { + "id": "comment-1", + "content": "Looks good", + "createdBy": _USER_RAW, + "createdAt": _NOW, + "updatedAt": _NOW, +} + +_ISSUE_RAW = { + "id": "issue-1", + "friendlyId": "I-42", + "labelId": "label-1", + "dataRowId": "dr-1", + "categoryId": "cat-1", + "content": "Something is wrong", + "position": None, + "status": "Open", + "createdBy": _USER_RAW, + "resolvedBy": None, + "createdAt": _NOW, + "updatedAt": _NOW, + "resolvedAt": None, + "contentUpdatedAt": None, + "latestReplyAt": None, +} + + +def _make_client(): + return MagicMock() + + +def _make_issue(client=None, overrides=None, project_id="proj-1"): + c = client or _make_client() + raw = {**_ISSUE_RAW, **(overrides or {})} + return _parse_issue(c, raw, project_id=project_id) + + +def _make_comment(client=None): + c = client or _make_client() + return _parse_comment(c, _COMMENT_RAW) + + +# --------------------------------------------------------------------------- +# _parse_issue / _parse_comment +# --------------------------------------------------------------------------- + + +class TestParseIssue: + def test_basic_fields(self): + issue = _make_issue() + assert issue.id == "issue-1" + assert issue.friendly_id == "I-42" + assert issue.content == "Something is wrong" + assert issue.status == IssueStatus.OPEN + assert issue.data_row_id == "dr-1" + assert issue.label_id == "label-1" + assert issue.category_id == "cat-1" + assert issue.position is None + + def test_created_by_is_user(self): + issue = _make_issue() + # User DbObject has .uid + assert issue.created_by.uid == "user-1" + + def test_resolved_by_none(self): + issue = _make_issue() + assert issue.resolved_by is None + + def test_resolved_by_present(self): + issue = _make_issue(overrides={"resolvedBy": _USER_RAW}) + assert issue.resolved_by.uid == "user-1" + + +class TestParseComment: + def test_basic_fields(self): + comment = _make_comment() + assert comment.id == "comment-1" + assert comment.content == "Looks good" + assert comment.created_by.uid == "user-1" + + +# --------------------------------------------------------------------------- +# Issue mutation methods +# --------------------------------------------------------------------------- + + +class TestIssueUpdate: + def test_update_content(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = { + "updateIssue": {**_ISSUE_RAW, "content": "Updated"} + } + updated = issue.update(content="Updated") + assert updated.content == "Updated" + # Verify GraphQL call + args, _ = client.execute.call_args + assert "UpdateIssuePyApi" in args[0] + assert args[1]["data"]["content"] == "Updated" + + def test_update_no_args_returns_self(self): + client = _make_client() + issue = _make_issue(client) + result = issue.update() + assert result is issue + client.execute.assert_not_called() + + +class TestIssueDelete: + def test_delete(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = {"deleteIssue": {"id": "issue-1"}} + assert issue.delete() is True + args, _ = client.execute.call_args + assert "DeleteIssuePyApi" in args[0] + assert args[1]["data"]["issueIds"] == ["issue-1"] + + +class TestIssueResolve: + def test_resolve(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = { + "resolveIssue": {**_ISSUE_RAW, "status": "Resolved"} + } + resolved = issue.resolve() + assert resolved.status == IssueStatus.RESOLVED + args, _ = client.execute.call_args + assert "ResolveIssuePyApi" in args[0] + + +class TestIssueReopen: + def test_reopen(self): + client = _make_client() + issue = _make_issue(client, overrides={"status": "Resolved"}) + client.execute.return_value = { + "openIssue": {**_ISSUE_RAW, "status": "Open"} + } + reopened = issue.reopen() + assert reopened.status == IssueStatus.OPEN + args, _ = client.execute.call_args + assert "OpenIssuePyApi" in args[0] + + +class TestIssueCreateComment: + def test_create_comment(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = {"createComment": _COMMENT_RAW} + comment = issue.create_comment(content="Nice work") + assert isinstance(comment, Comment) + args, _ = client.execute.call_args + assert "CreateCommentPyApi" in args[0] + assert args[1]["data"]["content"] == "Nice work" + assert args[1]["data"]["issueId"] == "issue-1" + + +# --------------------------------------------------------------------------- +# Issue accessor methods +# --------------------------------------------------------------------------- + + +class TestIssueComments: + def test_comments(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = {"issue": {"comments": [_COMMENT_RAW]}} + comments = issue.comments() + assert len(comments) == 1 + assert comments[0].id == "comment-1" + + def test_comments_empty(self): + client = _make_client() + issue = _make_issue(client) + client.execute.return_value = {"issue": {"comments": []}} + assert issue.comments() == [] + + +class TestIssueDataRow: + def test_data_row(self): + client = _make_client() + issue = _make_issue(client) + mock_dr = MagicMock() + client.get_data_row.return_value = mock_dr + result = issue.data_row() + assert result is mock_dr + client.get_data_row.assert_called_once_with("dr-1") + + def test_data_row_none_when_no_id(self): + client = _make_client() + issue = _make_issue(client, overrides={"dataRowId": None}) + assert issue.data_row() is None + client.get_data_row.assert_not_called() + + +class TestIssueCategory: + def test_category(self): + client = _make_client() + issue = _make_issue(client) + # category() queries project -> issueCategories and matches by id + client.execute.return_value = { + "project": { + "issueCategories": [ + { + "id": "cat-1", + "name": "Quality", + "description": "Quality issues", + } + ] + } + } + cat = issue.category() + assert cat is not None + assert cat.id == "cat-1" + assert cat.name == "Quality" + + def test_category_none_when_no_id(self): + client = _make_client() + issue = _make_issue(client, overrides={"categoryId": None}) + assert issue.category() is None + + +class TestIssueLabel: + def test_label(self): + client = _make_client() + issue = _make_issue(client) + mock_label = MagicMock() + client._get_single.return_value = mock_label + result = issue.label() + assert result is mock_label + client._get_single.assert_called_once() + + def test_label_none_when_no_id(self): + client = _make_client() + issue = _make_issue(client, overrides={"labelId": None}) + assert issue.label() is None + client._get_single.assert_not_called() + + +# --------------------------------------------------------------------------- +# Comment mutation methods +# --------------------------------------------------------------------------- + + +class TestCommentUpdate: + def test_update(self): + client = _make_client() + comment = _make_comment(client) + client.execute.return_value = { + "updateComment": {**_COMMENT_RAW, "content": "Revised"} + } + updated = comment.update(content="Revised") + assert updated.content == "Revised" + args, _ = client.execute.call_args + assert "UpdateCommentPyApi" in args[0] + + +class TestCommentDelete: + def test_delete(self): + client = _make_client() + comment = _make_comment(client) + client.execute.return_value = {"deleteComment": {"id": "comment-1"}} + assert comment.delete() is True + args, _ = client.execute.call_args + assert "DeleteCommentPyApi" in args[0] diff --git a/libs/labelbox/tests/unit/schema/test_issue_category.py b/libs/labelbox/tests/unit/schema/test_issue_category.py new file mode 100644 index 000000000..4f3c8d81c --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_issue_category.py @@ -0,0 +1,52 @@ +from unittest.mock import MagicMock + +from labelbox.schema.issue_category import IssueCategory + + +def _make_client(): + return MagicMock() + + +def _make_category(client=None): + c = client or _make_client() + cat = IssueCategory( + id="cat-1", + name="Quality", + description="Quality issues", + ) + cat._client = c + return cat + + +class TestIssueCategoryUpdate: + def test_update(self): + client = _make_client() + cat = _make_category(client) + client.execute.return_value = { + "editIssueCategory": { + "id": "cat-1", + "name": "Renamed", + "description": "New desc", + } + } + updated = cat.update(name="Renamed", description="New desc") + assert updated.name == "Renamed" + assert updated.description == "New desc" + args, _ = client.execute.call_args + assert "EditIssueCategoryPyApi" in args[0] + assert args[1]["where"] == {"id": "cat-1"} + assert args[1]["data"] == { + "name": "Renamed", + "description": "New desc", + } + + +class TestIssueCategoryDelete: + def test_delete(self): + client = _make_client() + cat = _make_category(client) + client.execute.return_value = {"deleteIssueCategory": {"id": "cat-1"}} + assert cat.delete() is True + args, _ = client.execute.call_args + assert "DeleteIssueCategoryPyApi" in args[0] + assert args[1]["where"] == {"id": "cat-1"} diff --git a/libs/labelbox/tests/unit/schema/test_issue_position.py b/libs/labelbox/tests/unit/schema/test_issue_position.py new file mode 100644 index 000000000..39998010e --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_issue_position.py @@ -0,0 +1,250 @@ +import logging + +import pytest + +from labelbox.schema.issue_position import ( + MEDIA_TYPE_POSITION_MAP, + ImageIssuePosition, + PdfIssuePosition, + TextIssuePosition, + VideoFrameRange, + VideoIssuePosition, + _deserialize_position, +) +from labelbox.schema.media_type import MediaType + + +# --------------------------------------------------------------------------- +# ImageIssuePosition +# --------------------------------------------------------------------------- + + +class TestImageIssuePosition: + def test_to_dict(self): + pos = ImageIssuePosition(x=100, y=200) + assert pos.to_dict() == { + "type": "Point", + "coordinates": [100, 200], + } + + def test_integer_coordinates(self): + pos = ImageIssuePosition(x=0, y=0) + assert isinstance(pos.x, int) + assert isinstance(pos.y, int) + + +# --------------------------------------------------------------------------- +# PdfIssuePosition +# --------------------------------------------------------------------------- + + +class TestPdfIssuePosition: + def test_to_dict(self): + pos = PdfIssuePosition(x=0.5, y=0.75, page=2) + assert pos.to_dict() == { + "type": "Point", + "coordinates": [0.5, 0.75], + "page": 2, + "unit": "PERCENT", + } + + def test_validation_x_out_of_range(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PdfIssuePosition(x=1.5, y=0.5, page=0) + + def test_validation_y_out_of_range(self): + with pytest.raises(ValueError, match="0.0 and 1.0"): + PdfIssuePosition(x=0.5, y=-0.1, page=0) + + def test_boundary_values(self): + pos_min = PdfIssuePosition(x=0.0, y=0.0, page=0) + assert pos_min.x == 0.0 + pos_max = PdfIssuePosition(x=1.0, y=1.0, page=0) + assert pos_max.x == 1.0 + + +# --------------------------------------------------------------------------- +# TextIssuePosition +# --------------------------------------------------------------------------- + + +class TestTextIssuePosition: + def test_to_dict(self): + pos = TextIssuePosition( + text_block_id="block-1", + start_char_index=10, + end_char_index=25, + ) + assert pos.to_dict() == { + "textBlockId": "block-1", + "startCharIndex": 10, + "endCharIndex": 25, + } + + +# --------------------------------------------------------------------------- +# VideoIssuePosition +# --------------------------------------------------------------------------- + + +class TestVideoIssuePosition: + def test_single_frame(self): + pos = VideoIssuePosition( + frames=[VideoFrameRange(start=5, end=5, x=100, y=200)] + ) + result = pos.to_dict() + assert result["type"] == "KeyframesGeoJSONPoint" + assert len(result["keyframes"]) == 1 + kf = result["keyframes"][0] + assert kf["frame"] == 5 + assert kf["value"]["coordinates"] == [100, 200] + + def test_contiguous_range(self): + pos = VideoIssuePosition( + frames=[VideoFrameRange(start=5, end=11, x=450, y=300)] + ) + result = pos.to_dict() + assert len(result["keyframes"]) == 2 + assert result["keyframes"][0]["frame"] == 5 + assert result["keyframes"][1]["frame"] == 11 + # No end_x/end_y => coordinates repeat + assert result["keyframes"][1]["value"]["coordinates"] == [450, 300] + + def test_moving_coordinates(self): + pos = VideoIssuePosition( + frames=[ + VideoFrameRange( + start=5, end=11, x=450, y=300, end_x=500, end_y=350 + ) + ] + ) + result = pos.to_dict() + assert len(result["keyframes"]) == 2 + assert result["keyframes"][0]["value"]["coordinates"] == [450, 300] + assert result["keyframes"][1]["value"]["coordinates"] == [500, 350] + + def test_multiple_ranges(self): + pos = VideoIssuePosition( + frames=[ + VideoFrameRange(start=5, end=11, x=450, y=300), + VideoFrameRange(start=20, end=25, x=100, y=100), + ] + ) + result = pos.to_dict() + assert len(result["keyframes"]) == 4 + + def test_single_frame_ignores_end_coords(self): + """When start == end, end_x/end_y are not serialized.""" + pos = VideoIssuePosition( + frames=[ + VideoFrameRange( + start=5, end=5, x=100, y=200, end_x=999, end_y=999 + ) + ] + ) + result = pos.to_dict() + assert len(result["keyframes"]) == 1 + assert result["keyframes"][0]["value"]["coordinates"] == [100, 200] + + +# --------------------------------------------------------------------------- +# MEDIA_TYPE_POSITION_MAP +# --------------------------------------------------------------------------- + + +class TestMediaTypePositionMap: + def test_image(self): + assert MEDIA_TYPE_POSITION_MAP[MediaType.Image] is ImageIssuePosition + + def test_video(self): + assert MEDIA_TYPE_POSITION_MAP[MediaType.Video] is VideoIssuePosition + + def test_text(self): + assert MEDIA_TYPE_POSITION_MAP[MediaType.Text] is TextIssuePosition + + def test_document(self): + assert MEDIA_TYPE_POSITION_MAP[MediaType.Document] is PdfIssuePosition + + def test_pdf(self): + assert MEDIA_TYPE_POSITION_MAP[MediaType.Pdf] is PdfIssuePosition + + def test_audio_not_in_map(self): + assert MediaType.Audio not in MEDIA_TYPE_POSITION_MAP + + +# --------------------------------------------------------------------------- +# _deserialize_position +# --------------------------------------------------------------------------- + + +class TestDeserializePosition: + def test_none_input(self): + assert _deserialize_position(None) is None + + def test_image_geojson(self): + raw = {"type": "Point", "coordinates": [100, 200]} + result = _deserialize_position(raw) + assert isinstance(result, ImageIssuePosition) + assert result.x == 100 + assert result.y == 200 + + def test_pdf_geojson(self): + raw = { + "type": "Point", + "coordinates": [0.5, 0.75], + "page": 2, + "unit": "PERCENT", + } + result = _deserialize_position(raw) + assert isinstance(result, PdfIssuePosition) + assert result.page == 2 + + def test_text_position(self): + raw = { + "textBlockId": "block-1", + "startCharIndex": 10, + "endCharIndex": 25, + } + result = _deserialize_position(raw) + assert isinstance(result, TextIssuePosition) + assert result.text_block_id == "block-1" + + def test_video_position(self): + raw = { + "type": "KeyframesGeoJSONPoint", + "keyframes": [ + { + "frame": 5, + "value": {"type": "Point", "coordinates": [100, 200]}, + }, + { + "frame": 11, + "value": {"type": "Point", "coordinates": [150, 250]}, + }, + ], + } + result = _deserialize_position(raw) + assert isinstance(result, VideoIssuePosition) + assert len(result.frames) == 1 + assert result.frames[0].start == 5 + assert result.frames[0].end == 11 + + def test_json_string_input(self): + import json + + raw = json.dumps({"type": "Point", "coordinates": [10, 20]}) + result = _deserialize_position(raw) + assert isinstance(result, ImageIssuePosition) + assert result.x == 10 + + def test_unrecognized_structure_returns_none(self, caplog): + raw = {"unknown_key": "some_value"} + with caplog.at_level(logging.WARNING): + result = _deserialize_position(raw) + assert result is None + assert "Unrecognized issue position structure" in caplog.text + + def test_invalid_json_string_returns_none(self, caplog): + with caplog.at_level(logging.WARNING): + result = _deserialize_position("not-valid-json") + assert result is None diff --git a/libs/labelbox/tests/unit/schema/test_project_issues.py b/libs/labelbox/tests/unit/schema/test_project_issues.py new file mode 100644 index 000000000..2d814a0ff --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_project_issues.py @@ -0,0 +1,376 @@ +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from labelbox.schema.issue import Issue, IssueStatus +from labelbox.schema.issue_category import IssueCategory +from labelbox.schema.issue_position import ( + ImageIssuePosition, + PdfIssuePosition, + VideoFrameRange, + VideoIssuePosition, + _deserialize_position, +) +from labelbox.schema.project import Project + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOW = "2025-01-15T10:00:00.000Z" + +_USER_RAW = { + "id": "user-1", + "email": "alice@example.com", + "nickname": "alice", + "name": "Alice", + "picture": "", + "isViewer": False, + "isExternalUser": False, + "createdAt": _NOW, + "updatedAt": _NOW, +} + +_ISSUE_RAW = { + "id": "issue-1", + "friendlyId": "I-42", + "labelId": "label-1", + "dataRowId": "dr-1", + "categoryId": "cat-1", + "content": "Something is wrong", + "position": None, + "status": "Open", + "createdBy": _USER_RAW, + "resolvedBy": None, + "createdAt": _NOW, + "updatedAt": _NOW, + "resolvedAt": None, + "contentUpdatedAt": None, + "latestReplyAt": None, +} + + +def _make_client(): + return MagicMock() + + +def _project_field_values(media_type="IMAGE"): + """Minimal field values needed to construct a ``Project`` DbObject.""" + return { + "id": "proj-1", + "name": "Test Project", + "description": "", + "updatedAt": _NOW, + "createdAt": _NOW, + "setupComplete": None, + "lastActivityTime": None, + "autoAuditNumberOfLabels": 1, + "autoAuditPercentage": 0.0, + "allowedMediaType": media_type, + "editorTaskType": None, + "dataRowCount": 0, + "modelSetupComplete": None, + "uploadType": None, + "isBenchmarkEnabled": False, + "isConsensusEnabled": False, + # Relationships with cache=True need a value + "ontology": {"id": "onto-1", "name": "test", "normalized": "{}"}, + } + + +def _make_project(client=None, media_type="IMAGE"): + c = client or _make_client() + return Project(c, _project_field_values(media_type)) + + +# --------------------------------------------------------------------------- +# create_issue +# --------------------------------------------------------------------------- + + +class TestCreateIssue: + def test_basic(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + issue = project.create_issue( + content="Something is wrong", + data_row_id="dr-1", + ) + assert isinstance(issue, Issue) + assert issue.id == "issue-1" + args, _ = client.execute.call_args + assert "CreateIssuePyApi" in args[0] + data = args[1]["data"] + assert data["content"] == "Something is wrong" + assert data["projectId"] == "proj-1" + assert data["dataRowId"] == "dr-1" + assert data["type"] == "Issue" + + def test_with_label_and_category(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + project.create_issue( + content="Issue", + data_row_id="dr-1", + label_id="label-1", + category_id="cat-1", + ) + args, _ = client.execute.call_args + data = args[1]["data"] + assert data["labelId"] == "label-1" + assert data["categoryId"] == "cat-1" + + def test_with_image_position(self): + client = _make_client() + project = _make_project(client, media_type="IMAGE") + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + pos = ImageIssuePosition(x=100, y=200) + project.create_issue( + content="Pin here", + data_row_id="dr-1", + position=pos, + ) + args, _ = client.execute.call_args + data = args[1]["data"] + assert data["position"] == {"type": "Point", "coordinates": [100, 200]} + + def test_position_validation_wrong_type(self): + client = _make_client() + project = _make_project(client, media_type="IMAGE") + + with pytest.raises(TypeError, match="PdfIssuePosition"): + project.create_issue( + content="Wrong position", + data_row_id="dr-1", + position=PdfIssuePosition(x=0.5, y=0.5, page=0), + ) + + def test_accepts_datarow_object(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + mock_dr = SimpleNamespace(uid="dr-obj-1") + project.create_issue(content="Test", data_row_id=mock_dr) + args, _ = client.execute.call_args + assert args[1]["data"]["dataRowId"] == "dr-obj-1" + + def test_accepts_label_object(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + mock_label = SimpleNamespace(uid="label-obj-1") + project.create_issue( + content="Test", + data_row_id="dr-1", + label_id=mock_label, + ) + args, _ = client.execute.call_args + assert args[1]["data"]["labelId"] == "label-obj-1" + + def test_accepts_category_object(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + mock_cat = SimpleNamespace(uid="cat-obj-1") + project.create_issue( + content="Test", + data_row_id="dr-1", + category_id=mock_cat, + ) + args, _ = client.execute.call_args + assert args[1]["data"]["categoryId"] == "cat-obj-1" + + def test_no_position_validation_when_media_type_none(self): + """Projects with unknown media type should not raise on position.""" + client = _make_client() + project = _make_project(client, media_type=None) + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + pos = ImageIssuePosition(x=10, y=20) + project.create_issue( + content="Test", + data_row_id="dr-1", + position=pos, + ) + # No error means success + + def test_video_position_on_video_project(self): + client = _make_client() + project = _make_project(client, media_type="VIDEO") + client.execute.return_value = {"createIssue": _ISSUE_RAW} + + pos = VideoIssuePosition( + frames=[VideoFrameRange(start=5, end=5, x=100, y=200)] + ) + project.create_issue( + content="Video issue", + data_row_id="dr-1", + position=pos, + ) + args, _ = client.execute.call_args + assert args[1]["data"]["position"]["type"] == "KeyframesGeoJSONPoint" + + +# --------------------------------------------------------------------------- +# get_issues +# --------------------------------------------------------------------------- + + +class TestGetIssues: + def test_returns_paginated_collection(self): + client = _make_client() + project = _make_project(client) + result = project.get_issues() + # PaginatedCollection is returned (lazy); no execute call yet + from labelbox.pagination import PaginatedCollection + + assert isinstance(result, PaginatedCollection) + + def test_with_status_filter(self): + client = _make_client() + project = _make_project(client) + result = project.get_issues(status=IssueStatus.OPEN) + # The params should contain the status filter + assert result.paginator.params["where"]["status"] == "Open" + + def test_with_data_row_filter(self): + client = _make_client() + project = _make_project(client) + result = project.get_issues(data_row_id="dr-1") + assert result.paginator.params["where"]["dataRow"] == {"id": "dr-1"} + + +# --------------------------------------------------------------------------- +# get_issue +# --------------------------------------------------------------------------- + + +class TestGetIssue: + def test_get_single_issue(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"issue": _ISSUE_RAW} + + issue = project.get_issue("issue-1") + assert issue.id == "issue-1" + args, _ = client.execute.call_args + assert "GetIssuePyApi" in args[0] + assert args[1]["where"] == {"id": "issue-1"} + + +# --------------------------------------------------------------------------- +# delete_issues +# --------------------------------------------------------------------------- + + +class TestDeleteIssues: + def test_delete_single(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"deleteIssue": {"id": "issue-1"}} + + assert project.delete_issues(["issue-1"]) is True + args, _ = client.execute.call_args + assert "DeleteIssuePyApi" in args[0] + assert args[1]["data"]["issueIds"] == ["issue-1"] + + def test_delete_multiple(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"deleteIssue": {"id": "issue-1"}} + + assert project.delete_issues(["issue-1", "issue-2"]) is True + args, _ = client.execute.call_args + assert args[1]["data"]["issueIds"] == ["issue-1", "issue-2"] + + +# --------------------------------------------------------------------------- +# create_issue_category +# --------------------------------------------------------------------------- + + +class TestCreateIssueCategory: + def test_create(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = { + "createIssueCategory": { + "id": "cat-1", + "name": "Quality", + "description": "Quality issues", + } + } + + cat = project.create_issue_category( + name="Quality", description="Quality issues" + ) + assert isinstance(cat, IssueCategory) + assert cat.id == "cat-1" + assert cat.name == "Quality" + args, _ = client.execute.call_args + assert "CreateIssueCategoryPyApi" in args[0] + assert args[1]["data"]["projectId"] == "proj-1" + + +# --------------------------------------------------------------------------- +# get_issue_categories +# --------------------------------------------------------------------------- + + +class TestGetIssueCategories: + def test_get_categories(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = { + "project": { + "issueCategories": [ + { + "id": "cat-1", + "name": "Quality", + "description": "Quality issues", + }, + { + "id": "cat-2", + "name": "Labeling", + "description": "Labeling issues", + }, + ] + } + } + + cats = project.get_issue_categories() + assert len(cats) == 2 + assert cats[0].name == "Quality" + assert cats[1].name == "Labeling" + + def test_empty_categories(self): + client = _make_client() + project = _make_project(client) + client.execute.return_value = {"project": {"issueCategories": []}} + + cats = project.get_issue_categories() + assert cats == [] + + +# --------------------------------------------------------------------------- +# _deserialize_position fallback +# --------------------------------------------------------------------------- + + +class TestDeserializePositionFallback: + def test_unrecognized_returns_none_and_warns(self, caplog): + raw = {"totally": "unknown", "structure": True} + with caplog.at_level(logging.WARNING): + result = _deserialize_position(raw) + assert result is None + assert "Unrecognized issue position structure" in caplog.text