# coding: utf-8
"""Create and manage annotation issues and comments in Supervisely."""
from __future__ import annotations
from typing import Dict, List, Literal, NamedTuple, Optional, Union
from supervisely.api.annotation_api import AnnotationInfo
from supervisely.api.module_api import ApiField, ModuleApiBase
from supervisely.project.project_meta import ProjectMeta
# TODO: Update autodocs configuration to include this module.
class CommentInfo(NamedTuple):
"""Class that represents information about a comment."""
id: int
issue_id: int
created_by: int
created_at: str
comment: str
meta: Dict
created_by_user: str
links: Dict
@classmethod
def from_json(cls, data: Dict) -> CommentInfo:
"""
Create an instance of the class from JSON data.
:param data: JSON data.
:type data: Dict
:returns: Instance of the class.
:rtype: :class:`~supervisely.api.issues_api.CommentInfo`
"""
return cls(
id=data.get(ApiField.ID),
issue_id=data.get(ApiField.ISSUE_ID),
created_by=data.get(ApiField.CREATED_BY_ID[0][0]),
created_at=data.get(ApiField.CREATED_AT),
comment=data.get(ApiField.COMMENT),
meta=data.get(ApiField.META),
created_by_user=data.get(ApiField.CREATED_BY_USER),
links=data.get(ApiField.LINKS),
)
class IssueInfo(NamedTuple):
"""Class that represents information about an issue."""
id: int
status: str
user_login: str
image_id: int
created_by: int
created_at: str
updated_at: str
dataset_id: int
project_id: int
image_name: str
name: str
[docs]
class IssuesApi(ModuleApiBase):
"""API for working with annotation issues and comments."""
def __init__(self, api):
"""
:param api: :class:`~supervisely.api.api.Api` object to use for API connection.
:type api: :class:`~supervisely.api.api.Api`
:Usage Example:
.. code-block:: python
import supervisely as sly
api = sly.Api.from_env()
issues = api.issues.get_list(team_id=1)
"""
super().__init__(api)
[docs]
@staticmethod
def info_sequence():
"""Sequence of fields that are returned by the API to represent IssueInfo."""
return [
ApiField.ID,
ApiField.STATUS,
ApiField.USER_LOGIN,
ApiField.IMAGE_ID,
ApiField.CREATED_BY_ID,
ApiField.CREATED_AT,
ApiField.UPDATED_AT,
ApiField.DATASET_ID,
ApiField.PROJECT_ID,
ApiField.IMAGE_NAME,
ApiField.NAME,
]
[docs]
@staticmethod
def info_tuple_name():
"""Name of the tuple that represents IssueInfo."""
return "IssueInfo"
[docs]
def get_list(self, team_id: int, filters: List[Dict[str, str]] = None) -> List[IssueInfo]:
"""Get list of issues in the specified team.
:param team_id: Team ID.
:type team_id: int
:param filters: List of filters to apply to the list of issues.
:type filters: List[Dict[str, str]], optional
:returns: List of issues.
:rtype: List[:class:`~supervisely.api.issues_api.IssueInfo`]
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
# Get list of issues in specified team.
issues = api.issues.get_list(team_id=1)
"""
return self.get_list_all_pages(
"issues.list",
{ApiField.FILTER: filters or [], ApiField.TEAM_ID: team_id},
)
[docs]
def get_info_by_id(self, id: int) -> IssueInfo:
"""Get information about the issue by its ID.
:param id: Issue ID.
:type id: int
:returns: Information about the issue.
:rtype: :class:`~supervisely.api.issues_api.IssueInfo`
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
# Get information about the issue by its ID.
issue_info = api.issues.get_info_by_id(1)
"""
response = self._get_response_by_id(id, "issues.info", id_field=ApiField.ID)
return (
self._convert_json_info(response.json(), skip_missing=True)
if (response is not None)
else None
)
[docs]
def add(
self,
team_id: int,
issue_name: str,
comment: Optional[str] = None,
assignees: Optional[List[int]] = None,
is_local: bool = False,
) -> IssueInfo:
"""Add a new issue and return information about it.
:param team_id: Team ID.
:type team_id: int
:param issue_name: Name of the issue.
:type issue_name: str
:param comment: Comment for the issue.
:type comment: str, optional
:param assignees: List of user IDs to assign the issue.
:type assignees: List[int], optional
:param is_local: The local issue will be available only for the members of the team, where it was
created. If set to False, the issue will be available for all users from all teams.
:type is_local: bool
:returns: Information about the added issue.
:rtype: :class:`~supervisely.api.issues_api.IssueInfo`
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
# Add new issue.
new_issue = api.issues.add(team_id=1, issue_name="New issue", comment="Some comment")
"""
response = self._api.post(
"issues.add",
{
ApiField.NAME: issue_name,
ApiField.COMMENT: comment or "",
ApiField.ASSIGNEES: assignees or [],
ApiField.TEAM_ID: team_id,
ApiField.IS_LOCAL: is_local,
},
)
issue_id = response.json().get(ApiField.ID)
# * At the moment API returns only ID of the issue (e.g. {"id": 123}).
# * So, we're making extra request to get full info about the issue.
# * Consider to update API to return full info about the issue.
return self.get_info_by_id(issue_id)
[docs]
def update(
self,
issue_id: int,
issue_name: Optional[str] = None,
status: Optional[Literal["open", "closed"]] = None,
is_pinned: Optional[bool] = None,
) -> IssueInfo:
"""Update information about the issue.
:param issue_id: Issue ID.
:type issue_id: int
:param issue_name: New name of the issue.
:type issue_name: str, optional
:param status: New status of the issue.
:type status: str, optional
:param is_pinned: Whether the issue is pinned.
:type is_pinned: bool, optional
:raises ValueError: if the status is incorrect. Expected one of ["open", "closed"], got {status}
:returns: Information about the issue.
:rtype: :class:`~supervisely.api.issues_api.IssueInfo`
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
# Update information about the issue.
updated_issue = api.issues.update(issue_id=1, issue_name="Updated issue name")
"""
available_statuses = ["open", "closed"]
if status is not None and status not in available_statuses:
raise ValueError(
f"Incorrect status, expected one of {available_statuses}, got {status}"
)
payload = {
ApiField.ID: issue_id,
ApiField.NAME: issue_name,
ApiField.STATUS: status,
ApiField.IS_PINNED: is_pinned,
}
payload = {k: v for k, v in payload.items() if v is not None}
self._api.post("issues.editInfo", payload)
# * Consider to update API to return full info about the issue without extra request.
return self.get_info_by_id(issue_id)
[docs]
def remove(self, issue_id: int) -> None:
"""
Remove the issue by its ID.
NOTE: This operation is irreversible.
:param issue_id: Issue ID.
:type issue_id: int
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
# Remove the issue by its ID.
api.issues.remove(issue_id=1)
"""
self._api.post("issues.remove", {ApiField.ID: issue_id})
def _create_bindings(self, label_id: int, image_id: int) -> Dict[str, Union[str, int, Dict[str, int]]]:
"""Create bindings from the given parameters.
:param label_id: Label ID.
:type label_id: int
:param image_id: Image ID.
:type image_id: int
:returns: Bindings.
:rtype: Dict[str, Union[str, int, Dict[str, int]]]
"""
# NOTE: This method is designed to handle the bindings for different cases,
# e.g. linking dataset, project, etc. At the moment, it's used for linking
# the issue with the image. Later, it can be extended to handle other cases.
# In this case parameters should be optional.
return {
ApiField.FIELD: ApiField.FIGURE_ID,
ApiField.VALUE: label_id,
ApiField.EXTRA: {ApiField.FIGURE_IMAGE_ID: image_id},
}
[docs]
def add_subissue(
self,
issue_id: int,
image_ids: Union[int, List[int]],
label_ids: Union[int, List[int]],
top: Union[int, float],
left: Union[int, float],
annotation_info: AnnotationInfo,
project_meta: ProjectMeta,
) -> None:
"""
Add a subissue to the specified issue.
Image and label IDs should be the same type, e.g. both int or list of ints.
If they are lists, they should have the same length.
Annotation info should be an instance of AnnotationInfo, not :class:`~supervisely.annotation.annotation.Annotation`, since the
second one does not contain required information.
:param issue_id: Issue ID.
:type issue_id: int
:param image_ids: Image ID or list of image IDs to be binded with the issue.
:type image_ids: Union[int, List[int]]
:param label_ids: Label ID or list of label IDs to be binded with the issue.
:type label_ids: Union[int, List[int]]
:param top: Top position of the marker of subissue in the Labeling interface.
:type top: Union[int, float]
:param left: Left position of the marker of subissue in the Labeling interface.
:type left: Union[int, float]
:param annotation_info: Information about the annotation.
:type annotation_info: :class:`~supervisely.api.annotation_api.AnnotationInfo`
:param project_meta: Project meta information.
:type project_meta: :class:`~supervisely.project.project_meta.ProjectMeta`
:returns: None
:rtype: None
:Usage Example:
.. code-block:: python
import os
from dotenv import load_dotenv
import supervisely as sly
# Load secrets and create API object from .env file (recommended)
# Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
if sly.is_development():
load_dotenv(os.path.expanduser("~/supervisely.env"))
api = sly.Api.from_env()
project_id = 123
image_id = 456
label_id = 789
# Get project meta and annotation info.
project_meta = sly.ProjectMeta.from_json(api.project.get_meta(project_id))
annotation_info = api.annotation.download(image_id)
# Add a subissue to the specified issue.
api.issues.add_subissue(
issue_id=1,
image_ids=image_id,
label_ids=label_id,
top=100,
left=100,
annotation_info=annotation_info,
project_meta=project_meta
)
"""
# NOTE: DO NOT USE THIS METHOD IN PRODUCTION CODE.
# From the API side, there will be significant changes in the future which lead to
# changes in the method signature.
bindings = self._create_bindings(label_ids, image_ids)
if type(image_ids) != type(label_ids):
raise ValueError(
"Image ID and Label ID should be the same type, e.g. both int or list of ints."
)
if isinstance(image_ids, int):
image_ids = [image_ids]
label_ids = [label_ids]
if len(image_ids) != len(label_ids):
raise ValueError(
"Image ID and Label ID should have the same length when they are lists."
)
if not isinstance(annotation_info, AnnotationInfo):
raise ValueError("annotation_info should be an instance of AnnotationInfo.")
bindings = [
self._create_bindings(label_id, image_id)
for label_id, image_id in zip(label_ids, image_ids)
]
classes = project_meta.to_json()["classes"]
annotation_data = annotation_info.to_json()
annotation_data[ApiField.META] = {ApiField.CLASSES: classes}
payload = {
ApiField.ISSUE_ID: issue_id,
ApiField.BINDINGS: bindings,
ApiField.META: {
ApiField.POSITION: {ApiField.LEFT: left, ApiField.TOP: top},
ApiField.ANNOTATION_DATA: annotation_data,
},
ApiField.PARENT_ID: issue_id,
}
self._api.post("issues.sub-issue.add", payload)