diff --git a/hasty/__init__.py b/hasty/__init__.py index 01d8e9e..af167fa 100755 --- a/hasty/__init__.py +++ b/hasty/__init__.py @@ -1,3 +1,4 @@ +from hasty.activity import Activity, ActivityType from hasty.attribute import Attribute from hasty.client import Client from hasty.dataset import Dataset @@ -9,6 +10,7 @@ from hasty.project import Project from hasty.tag import Tag from hasty.tag_class import TagClass +from hasty.video import Video import hasty.label_utils as label_utils @@ -23,6 +25,8 @@ def int_or_str(value): VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ + 'Activity', + 'ActivityType' 'Attribute', 'Attributer', 'Client', @@ -37,5 +41,6 @@ def int_or_str(value): 'SemanticSegmentor', 'Tag', 'TagClass', - 'label_utils' + 'Video', + 'label_utils', ] diff --git a/hasty/activity.py b/hasty/activity.py new file mode 100644 index 0000000..b6dbc92 --- /dev/null +++ b/hasty/activity.py @@ -0,0 +1,203 @@ +from collections import OrderedDict + +from .exception import ValidationException +from .hasty_object import HastyObject + + +class ActivityType(HastyObject): + endpoint = '/v1/projects/{project_id}/activity_types' + endpoint_class = '/v1/projects/{project_id}/activity_types/{activity_type_id}' + + def __repr__(self): + return self.get__repr__(OrderedDict({"id": self._id, "name": self._name, "color": self._color})) + + @property + def id(self): + """ + :type: string + """ + return self._id + + @property + def name(self): + """ + :type: string + """ + return self._name + + @property + def project_id(self): + """ + :type: string + """ + return self._project_id + + @property + def color(self): + """ + :type: string + """ + return self._color + + def _init_properties(self): + self._id = None + self._name = None + self._project_id = None + self._color = None + + def _set_prop_values(self, data): + if "id" in data: + self._id = data["id"] + if "name" in data: + self._name = data["name"] + if "project_id" in data: + self._project_id = data["project_id"] + if "color" in data: + self._color = data["color"] + + @classmethod + def _create(cls, requester, project_id, name, color=None): + res = requester.post(cls.endpoint.format(project_id=project_id), + json_data={"name": name, + "color": color}) + return cls(requester, res, {"project_id": project_id}) + + def edit(self, name, color=None): + """ + Edit activity type properties + + Arguments: + name (str): Label class name + color (str, optional): Color in HEX format #0f0f0faa + """ + self._requester.put(self.endpoint_class.format(project_id=self._project_id, activity_type_id=self._id), + json_data={"name": name, "color": color}) + self._name = name + self._color = color + + def delete(self): + """ + Delete activity type + """ + self._requester.delete(self.endpoint_class.format(project_id=self._project_id, activity_type_id=self._id)) + + +class Activity(HastyObject): + endpoint = '/v1/projects/{project_id}/videos/{video_id}/segments' + endpoint_class = '/v1/projects/{project_id}/segments/{segment_id}' + + def __repr__(self): + return self.get__repr__(OrderedDict({"id": self._id, "activities": self._activities, + "start": self._start_time_ms, "end": self._end_time_ms})) + + @property + def id(self): + """ + :type: string + """ + return self._id + + @property + def video_id(self): + """ + :type: string + """ + return self._video_id + + @property + def activities(self): + """ + :type: list + """ + return self._activities + + @property + def start_time_ms(self): + """ + :type: int + """ + return self._start_time_ms + + @property + def end_time_ms(self): + """ + :type: int + """ + return self._end_time_ms + + def _init_properties(self): + self._id = None + self._video_id = None + self._activities = None + self._start_time_ms = None + self._end_time_ms = None + self._project_id = None + + def _set_prop_values(self, data): + if "id" in data: + self._id = data["id"] + if "video_id" in data: + self._video_id = data["video_id"] + if "activities" in data: + self._activities = data["activities"] + if "start_time_ms" in data: + self._start_time_ms = data["start_time_ms"] + if "end_time_ms" in data: + self._end_time_ms = data["end_time_ms"] + if "project_id" in data: + self._project_id = data["project_id"] + + @classmethod + def _create(cls, requester, project_id, video_id, activities, + start_time_ms, end_time_ms, replace_overlap=False): + if len(activities) == 0 or not isinstance(activities, list): + raise ValidationException.invalid_activities() + type_ids = [] + for a in activities: + if isinstance(a, ActivityType): + type_ids.append(a.id) + elif isinstance(a, str): + type_ids.append(a) + else: + raise ValidationException.invalid_activities() + query_params = None + if replace_overlap: + query_params = {"replace_overlap": replace_overlap} + res = requester.post(cls.endpoint.format(project_id=project_id, video_id=video_id), + json_data={"activities": type_ids, + "start_time_ms": start_time_ms, + "end_time_ms": end_time_ms}, + query_params=query_params) + return cls(requester, res, {"project_id": project_id}) + + def delete(self): + """ + Delete activity + """ + self._requester.delete(self.endpoint_class.format(project_id=self._project_id, segment_id=self._id)) + + def edit(self, start_time_ms, end_time_ms, activities): + """ + Edit activity properties + + Arguments: + start_time_ms (int): Start time in milliseconds + end_time_ms (int): End time in milliseconds + activities (list): List of `~hasty.ActivityType` or `str` (IDs) + """ + if len(activities) == 0 or not isinstance(activities, list): + raise ValidationException.invalid_activities() + type_ids = [] + for a in activities: + if isinstance(a, ActivityType): + type_ids.append(a.id) + elif isinstance(a, str): + type_ids.append(a) + else: + raise ValidationException.invalid_activities() + res = self._requester.put(self.endpoint_class.format(project_id=self._project_id, segment_id=self._id), + json_data={"activities": type_ids, + "start_time_ms": start_time_ms, + "end_time_ms": end_time_ms}) + self._set_prop_values(res) + return Activity(self._requester, res, {"project_id": self._project_id}) diff --git a/hasty/client.py b/hasty/client.py index 70427be..9368cdb 100644 --- a/hasty/client.py +++ b/hasty/client.py @@ -1,8 +1,9 @@ from typing import Union import uuid +from .constants import ProjectType from .helper import PaginatedList -from .project import Project +from .project import Project, VideoProject from .workspace import Workspace from .requester import Requester @@ -49,16 +50,18 @@ def get_project(self, project_id): res = self._requester.get(Project.endpoint_project.format(project_id=project_id)) return Project(self._requester, res) - def create_project(self, workspace: Union[str, Workspace], name: str, description: str = None) -> Project: + def create_project(self, workspace: Union[str, Workspace], name: str, + description: str = None, content_type: str = ProjectType.Image) -> Union[Project, VideoProject]: """ - Creates new project :py:class:`~hasty.Project` + Creates new project :py:class:`~hasty.Project` or :py:class:`~hasty.VideoProject` Arguments: workspace (:py:class:`~hasty.Workspace`, str): Workspace object or id which the project should belongs to name (str): Name of the project description (str, optional): Project description + content_type (str, optional): Type of the project. Default is image """ workspace_id = workspace if isinstance(workspace, Workspace): workspace_id = workspace.id - return Project.create(self._requester, workspace_id, name, description) + return Project.create(self._requester, workspace_id, name, description, content_type) diff --git a/hasty/constants.py b/hasty/constants.py index 2493032..db5b23a 100644 --- a/hasty/constants.py +++ b/hasty/constants.py @@ -1,3 +1,8 @@ +class ProjectType: + Image = "IMAGES" + Video = "VIDEOS" + + class ImageStatus: New = "NEW" Done = "DONE" @@ -8,6 +13,15 @@ class ImageStatus: AutoLabelled = "AUTO-LABELLED" +class VideoStatus: + New = ImageStatus.New + InProgress = ImageStatus.InProgress + ToReview = ImageStatus.ToReview + Done = ImageStatus.Done + Skipped = ImageStatus.Skipped + Completed = ImageStatus.Completed + + class ExportFormat: JSON_v11 = "json_v1.1" SEMANTIC_PNG = "semantic_png" @@ -31,6 +45,8 @@ class SemanticOrder: VALID_STATUSES = [ImageStatus.New, ImageStatus.Done, ImageStatus.Skipped, ImageStatus.InProgress, ImageStatus.ToReview, ImageStatus.AutoLabelled, ImageStatus.Completed] +VALID_VIDEO_STATUSES = [VideoStatus.New, VideoStatus.Done, VideoStatus.Skipped, VideoStatus.InProgress, VideoStatus.ToReview, + VideoStatus.Completed] VALID_EXPORT_FORMATS = [ExportFormat.JSON_v11, ExportFormat.SEMANTIC_PNG, ExportFormat.JSON_COCO, ExportFormat.IMAGES] VALID_SEMANTIC_FORMATS = [SemanticFormat.GS_DESC, SemanticFormat.GS_ASC, SemanticFormat.CLASS_COLOR] VALID_SEMANTIC_ORDER = [SemanticOrder.Z_INDEX, SemanticOrder.CLASS_TYPE, SemanticOrder.CLASS_ORDER] diff --git a/hasty/exception.py b/hasty/exception.py index 5bee9bd..4766e7b 100644 --- a/hasty/exception.py +++ b/hasty/exception.py @@ -4,8 +4,15 @@ def __init__(self, message): @classmethod def export_in_progress(cls): - raise ValidationException("Export is still running") + return ValidationException("Export is still running") + @classmethod + def video_not_ready(cls): + return ValidationException("Video is not ready") + + @classmethod + def invalid_activities(cls): + return ValidationException("activities must be a non-empty list of ActitivityType objects or IDs") class AuthenticationException(Exception): def __init__(self, message): @@ -13,7 +20,7 @@ def __init__(self, message): @classmethod def failed_authentication(cls): - raise AuthenticationException("Authentication failed, check your API key") + return AuthenticationException("Authentication failed, check your API key") class AuthorisationException(Exception): @@ -22,7 +29,7 @@ def __init__(self, message): @classmethod def permission_denied(cls): - raise AuthorisationException("Looks like service account doesn't have a permission to perform this operation") + return AuthorisationException("Looks like service account doesn't have a permission to perform this operation") class InsufficientCredits(Exception): @@ -31,7 +38,7 @@ def __init__(self, message): @classmethod def insufficient_credits(cls): - raise InsufficientCredits("Looks like you out of credits, please top up your account or contact Hasty") + return InsufficientCredits("Looks like you out of credits, please top up your account or contact Hasty") class NotFound(Exception): @@ -40,7 +47,7 @@ def __init__(self, message): @classmethod def object_not_found(cls): - raise NotFound("Referred object not found, please check your script") + return NotFound("Referred object not found, please check your script") class LimitExceededException(Exception): diff --git a/hasty/hasty_object.py b/hasty/hasty_object.py index 64299b2..ececd1c 100644 --- a/hasty/hasty_object.py +++ b/hasty/hasty_object.py @@ -3,6 +3,7 @@ class HastyObject: + endpoint_uploads = '/v1/projects/{project_id}/uploads' def __init__(self, requester, data, obj_params=None): self._requester = requester @@ -37,3 +38,8 @@ def _init_properties(self): def _set_prop_values(self, data): raise NotImplementedError() + + @classmethod + def _generate_sign_url(cls, requester, project_id): + data = requester.get(cls.endpoint_uploads.format(project_id=project_id), query_params={"count": 1}) + return data["items"][0] diff --git a/hasty/image.py b/hasty/image.py index e6c1fb5..66be49e 100644 --- a/hasty/image.py +++ b/hasty/image.py @@ -16,7 +16,6 @@ class Image(HastyObject): endpoint = '/v1/projects/{project_id}/images' - endpoint_uploads = '/v1/projects/{project_id}/uploads' endpoint_image = '/v1/projects/{project_id}/images/{image_id}' def __repr__(self): @@ -131,15 +130,10 @@ def _get_by_id(requester, project_id, image_id): data = requester.get(Image.endpoint_image.format(project_id=project_id, image_id=image_id)) return Image(requester, data, {"project_id": project_id}) - @staticmethod - def _generate_sign_url(requester, project_id): - data = requester.get(Image.endpoint_uploads.format(project_id=project_id), query_params={"count": 1}) - return data["items"][0] - @staticmethod def _upload_from_file(requester, project_id, dataset_id, filepath, external_id: Optional[str] = None): filename = os.path.basename(filepath) - url_data = Image._generate_sign_url(requester, project_id) + url_data = HastyObject._generate_sign_url(requester, project_id) with open(filepath, 'rb') as f: requester.put(url_data['url'], data=f.read(), content_type="") res = requester.post(Image.endpoint.format(project_id=project_id), diff --git a/hasty/inference/base.py b/hasty/inference/base.py index 99d7319..e921289 100644 --- a/hasty/inference/base.py +++ b/hasty/inference/base.py @@ -58,7 +58,7 @@ def _predict(self, image_url: str, image_path: str, endpoint: str, if self._status != 'LOADED': raise InferenceException.model_not_loaded() if image_path is not None: - url_data = Image._generate_sign_url(self._requester, self._project_id) + url_data = HastyObject._generate_sign_url(self._requester, self._project_id) with open(image_path, 'rb') as f: self._requester.put(url_data['url'], data=f.read(), content_type="image/*") upload_id = url_data['id'] diff --git a/hasty/project.py b/hasty/project.py index a81c175..bf5b7dd 100644 --- a/hasty/project.py +++ b/hasty/project.py @@ -1,10 +1,11 @@ from collections import OrderedDict from typing import List, Union, Optional +from .activity import ActivityType from .attribute import Attribute from .automated_labeling import AutomatedLabelingJob from .constants import ImageStatus, SemanticFormat, VALID_EXPORT_FORMATS, VALID_SEMANTIC_ORDER, \ - VALID_SEMANTIC_FORMATS, VALID_STATUSES + VALID_SEMANTIC_FORMATS, VALID_STATUSES, VALID_VIDEO_STATUSES, ProjectType, VideoStatus from .dataset import Dataset from .export_job import ExportJob from .exception import ValidationException @@ -14,6 +15,7 @@ from .image import Image from .label_class import LabelClass from .tag_class import TagClass +from .video import Video class Project(HastyObject): @@ -68,11 +70,16 @@ def _set_prop_values(self, data): self._description = data["description"] @staticmethod - def create(requester, workspace_id, name, description): + def create(requester, workspace_id, name, description, content_type: str = ProjectType.Image): + data = {"workspace_id": workspace_id, + "name": name, + "description": description} + if content_type == ProjectType.Video: + data["content_type"] = content_type res = requester.post(Project.endpoint, - json_data={"workspace_id": workspace_id, - "name": name, - "description": description}) + json_data=data) + if content_type == ProjectType.Video: + return VideoProject(requester, res, {"workspace_id": workspace_id}) return Project(requester, res, {"workspace_id": workspace_id}) def edit(self, name, description): @@ -137,6 +144,7 @@ def get_images(self, dataset=None, image_status=None): - "IN PROGRESS" - "TO REVIEW" - "AUTO-LABELLED" + - "COMPLETED" """ query_params = {} @@ -456,3 +464,111 @@ def create_automated_labeling_job(self, experiment_id: str, confidence_threshold """ return AutomatedLabelingJob._create(self._requester, self._id, experiment_id, confidence_threshold, max_detections_per_image, num_images, masker_threshold, dataset_id) + + +class VideoProject(Project): + @staticmethod + def create(requester, workspace_id, name, description): + return Project.create(requester, workspace_id, name, description, content_type=ProjectType.Video) + + def export(self, name: str, export_format: str, dataset: Union[Dataset, str, List[Dataset], List[str]] = None, + video_status: Union[str, List[str]] = VideoStatus.Done, sign_urls: bool = False): + # TODO: Implement for video + ... + + def upload_from_file(self, dataset, filepath): + """ + Uploads video from the given filepath + + Args: + dataset (`~hasty.Dataset`, str): Dataset object or id that the vide should belong to + filepath (str): Local path + """ + dataset_id = dataset + if isinstance(dataset, Dataset): + dataset_id = dataset.id + return Video._upload_from_file(self._requester, self._id, dataset_id, filepath) + + def upload_from_url(self, dataset: Union[Dataset, str], filename: str, url: str): + """ + Uploads video from a given URL + + Args: + dataset (`~hasty.Dataset`, str): Dataset object or id that the video should belong to + filename (str): Filename of the video + url (str): Video url + """ + dataset_id = dataset + if isinstance(dataset, Dataset): + dataset_id = dataset.id + return Video._upload_from_url(self._requester, self._id, dataset_id, filename, url) + + def get_videos(self, dataset=None, video_status=None): + """ + Retrieves the list of projects videos. + + Args: + dataset (str, `~hasty.Dataset`, list of str, list of `~hasty.Dataset`): filter videos by dataset + video_status (str, list of str): Filters videos by status, valid values are: + + - "NEW" + - "DONE", + - "SKIPPED" + - "IN PROGRESS" + - "TO REVIEW" + - "COMPLETED" + + """ + query_params = {} + if dataset: + if isinstance(dataset, str): + query_params["dataset_id"] = dataset + elif isinstance(dataset, Dataset): + query_params["dataset_id"] = dataset.id + elif isinstance(dataset, list): + dataset_ids = [] + for d in dataset: + if isinstance(d, str): + dataset_ids.append(d) + elif isinstance(d, Dataset): + dataset_ids.append(d.id) + query_params["dataset_id"] = ','.join(dataset_ids) + if video_status: + if isinstance(video_status, str): + query_params["video_status"] = video_status + elif isinstance(video_status, list): + video_statuses = [] + for status in video_status: + if status in VALID_VIDEO_STATUSES: + video_statuses.append(status) + query_params["video_status"] = ','.join(video_statuses) + return PaginatedList(Video, self._requester, + Video.endpoint.format(project_id=self._id), obj_params={"project_id": self.id}, + query_params=query_params) + + def get_video(self, video_id: str): + """ + Retrieves the video by its id. + + Args: + video_id (str): Video ID + """ + return Video._get_by_id(self._requester, self._id, video_id) + + def get_activity_types(self): + """ + Get label classes, list of :py:class:`~hasty.ActivityType` objects. + """ + return PaginatedList(ActivityType, self._requester, + ActivityType.endpoint.format(project_id=self._id), + obj_params={"project_id": self.id}) + + def create_activity_type(self, name: str, color: Optional[str]): + """ + Create tag class, returns :py:class:`~hasty.ActivityType` object. + + Args: + name (str): Activity type name + color (str, optional): Color in HEX format #0f0f0faa + """ + return ActivityType._create(self._requester, self._id, name, color) diff --git a/hasty/requester.py b/hasty/requester.py index 19647b9..57eb96a 100644 --- a/hasty/requester.py +++ b/hasty/requester.py @@ -62,11 +62,12 @@ def get(self, endpoint, query_params=None): cookies=self.cookies).json() return json_data - def post(self, endpoint, json_data=None, content_type='application/json'): + def post(self, endpoint, json_data=None, content_type='application/json', query_params=None): self.headers['Content-Type'] = content_type return self.request("POST", endpoint, headers=self.headers, json_data=json_data, + params=query_params, cookies=self.cookies).json() def put(self, endpoint, data=None, files=None, content_type='application/json', json_data=None): diff --git a/hasty/video.py b/hasty/video.py new file mode 100644 index 0000000..5f9949b --- /dev/null +++ b/hasty/video.py @@ -0,0 +1,272 @@ +import os +from typing import List, Optional, Union +import urllib.error +import urllib.request + +from .hasty_object import HastyObject +from .activity import ActivityType, Activity +from .dataset import Dataset +from .helper import PaginatedList +from .constants import VideoStatus, VALID_VIDEO_STATUSES +from .exception import ValidationException + + +class Video(HastyObject): + endpoint = '/v1/projects/{project_id}/videos' + endpoint_video = '/v1/projects/{project_id}/videos/{video_id}' + + @property + def id(self): + """ + :type: string + """ + return self._id + + @property + def name(self): + """ + :type: string + """ + return self._name + + @property + def project_id(self): + """ + :type: string + """ + return self._project_id + + @property + def dataset_id(self): + """ + :type: string + """ + return self._dataset_id + + @property + def status(self): + """ + :type: string + """ + return self._status + + @property + def width(self): + """ + :type: int + """ + return self._width + + @property + def height(self): + """ + :type: int + """ + return self._height + + @property + def public_url(self): + """ + :type: string + """ + return self._public_url + + @property + def format(self): + """ + :type: string + """ + return self._format + + @property + def duration_ms(self): + """ + :type: int + """ + return self._duration_ms + + @property + def duration_frames(self): + """ + :type: int + """ + return self._duration_frames + + @property + def health_status(self): + """ + :type: string + """ + return self._health_status + + def _init_properties(self): + self._id = None + self._name = None + self._project_id = None + self._dataset_id = None + self._status = None + self._public_url = None + self._width = None + self._height = None + self._format = None + self._duration_ms = None + self._duration_frames = None + self._health_status = None + + def _set_prop_values(self, data): + if "id" in data: + self._id = data["id"] + if "name" in data: + self._name = data["name"] + if "project_id" in data: + self._project_id = data["project_id"] + if "dataset_id" in data: + self._dataset_id = data["dataset_id"] + if "status" in data: + self._status = data["status"] + if "public_url" in data: + self._public_url = data["public_url"] + if "width" in data: + self._width = data["width"] + if "height" in data: + self._height = data["height"] + if "format" in data: + self._format = data["format"] + if "duration_ms" in data: + self._duration_ms = data["duration_ms"] + if "duration_frames" in data: + self._duration_frames = data["duration_frames"] + if "health_status" in data: + self._health_status = data["health_status"] + + @classmethod + def _get_by_id(cls, requester, project_id, video_id): + data = requester.get(cls.endpoint_video.format(project_id=project_id, video_id=video_id)) + return Video(requester, data, {"project_id": project_id}) + + @staticmethod + def _upload_from_file(requester, project_id, dataset_id, filepath: str = None): + filename = os.path.basename(filepath) + url_data = HastyObject._generate_sign_url(requester, project_id) + with open(filepath, 'rb') as f: + requester.put(url_data['url'], data=f.read(), content_type="") + res = requester.post(Video.endpoint.format(project_id=project_id), + json_data={"dataset_id": dataset_id, + "filename": filename, + "upload_id": url_data["id"]}) + return Video(requester, res, {"project_id": project_id, + "dataset_id": dataset_id}) + + @staticmethod + def _upload_from_url(requester, project_id, dataset_id, filename, url): + res = requester.post(Video.endpoint.format(project_id=project_id), + json_data={"dataset_id": dataset_id, + "filename": filename, + "url": url}) + return Video(requester, res, {"project_id": project_id, + "dataset_id": dataset_id}) + + def create_activity(self, start_time_ms: int, end_time_ms: int, + activity_types: Union[List[str], List[ActivityType]], replace_overlap=False): + """ + Create activity + + Args: + video_id (str): Video ID + start_time_ms (int): Start time in milliseconds + end_time_ms (int): End time in milliseconds + activity_types: List of `~hasty.ActivityType` or `str` (IDs) + replace_overlap: Replace overlapping activities + """ + return Activity._create(requester=self._requester, project_id=self._project_id, + video_id=self._id, activities=activity_types, + start_time_ms=start_time_ms, end_time_ms=end_time_ms, + replace_overlap=replace_overlap) + + def get_activities(self, activity_type_id: Optional[str] = None, + start_time_ms: Optional[int] = None, end_time_ms: Optional[int] = None): + """ + Returns activities (list of `~hasty.Activity` objects) for the video + """ + params = {} + if start_time_ms: + params["start_time_ms"] = start_time_ms + if end_time_ms: + params["end_time_ms"] = end_time_ms + if activity_type_id: + params["activity_type_id"] = activity_type_id + return PaginatedList(Activity, self._requester, + Activity.endpoint.format(project_id=self._project_id, video_id=self._id), + query_params=params, + obj_params={"project_id": self._project_id, "video_id": self._id}) + + def check_health(self) -> str: + """ + Checks health status of the video + """ + if self._health_status == "PROCESSING": + v = self._get_by_id(self._requester, self._project_id, self._id) + self._health_status = v._health_status + return self._health_status + + def set_status(self, status: str): + """ + Set video status + + Args: + status: New status one of ["NEW", "DONE", "SKIPPED", "IN PROGRESS", "TO REVIEW"] + """ + if status not in VALID_VIDEO_STATUSES: + raise ValidationException(f"Got {status}, expected on of {VALID_VIDEO_STATUSES}") + self._requester.put(Video.endpoint_video.format(project_id=self.project_id, + video_id=self.id) + "/status", + json_data={"status": status}) + self._status = status + + def download(self, filepath: str): + """ + Downloads video to file + + Args: + filepath (str): Local path + """ + if self.check_health() != "OK": + raise ValidationException.video_not_ready() + try: + urllib.request.urlretrieve(self._public_url, filepath) + except urllib.error.HTTPError as e: + if e.code == 404: + raise ValidationException.video_not_ready() + raise e + + def rename(self, new_name: str): + """ + Rename video + + Args: + new_name (str): New video name + """ + response = self._requester.patch(Video.endpoint_video.format(project_id=self.project_id, + video_id=self.id), + json_data={"filename": new_name}) + self._name = response.get("name") + + def move(self, dataset: Union[Dataset, str]): + """ + Move video to another dataset + """ + dataset_id = dataset + if isinstance(dataset, Dataset): + dataset_id = dataset.id + response = self._requester.patch(Video.endpoint_video.format(project_id=self.project_id, + video_id=self.id), + json_data={"dataset_id": dataset_id}) + self._dataset_id = response.get("dataset_id") + self._dataset_name = response.get("dataset_name") + + def delete(self): + """ + Removes video + """ + self._requester.delete(Video.endpoint_video.format(project_id=self.project_id, + video_id=self.id)) diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000..837e51a --- /dev/null +++ b/tests/test_activity.py @@ -0,0 +1,72 @@ +import os +import unittest +import urllib.request + +from hasty.constants import ProjectType +from tests.utils import get_client, vid_url + + +class TestActivity(unittest.TestCase): + def setUp(self): + self.h = get_client() + ws = self.h.get_workspaces() + ws0 = ws[0] + self.project = self.h.create_project(ws0, "Test Project", content_type=ProjectType.Video) + urllib.request.urlretrieve(vid_url, "tmp5.mp4") + ds = self.project.create_dataset("ds") + self.video = self.project.upload_from_file(ds, "tmp5.mp4") + + def test_activity_types(self): + act_types = self.project.get_activity_types() + self.assertEqual(0, len(act_types), 'Should be no activity types for a new project') + # Create activity type + lc = self.project.create_activity_type("act1", "#ff00aa99") + self.assertEqual(36, len(lc.id), 'Length of the id must be 36') + self.assertEqual("act1", lc.name) + self.assertEqual("#ff00aa99", lc.color) + # Update activity type + lc.edit("act2", "#ff88aa99") + self.assertEqual("act2", lc.name) + self.assertEqual("#ff88aa99", lc.color) + # Delete activity type + act_types = self.project.get_activity_types() + self.assertEqual(1, len(act_types), 'Should be one activity type') + lc.delete() + act_types = self.project.get_activity_types() + self.assertEqual(0, len(act_types), 'Should not be any activity type') + + def test_activities(self): + activities = self.video.get_activities() + self.assertEqual(0, len(activities), 'Should be no activities for a new video') + # Create activity + act_type = self.project.create_activity_type("act5", "#ff00aa99") + act = self.video.create_activity(500, 1000, [act_type]) + self.assertEqual([act_type.id], act.activities) + self.assertEqual(500, act.start_time_ms) + self.assertEqual(1000, act.end_time_ms) + activities = self.video.get_activities() + self.assertEqual(1, len(activities), 'Should be one activity') + # Edit activity + act_type2 = self.project.create_activity_type("act4", "#ff00aa99") + act.edit(2000, 10000, [act_type, act_type2]) + self.assertEqual(set([act_type.id, act_type2.id]), set(act.activities)) + self.assertEqual(2000, act.start_time_ms) + self.assertEqual(10000, act.end_time_ms) + activities = self.video.get_activities() + self.assertEqual(1, len(activities), 'Should be one activity') + # Delete activity + act.delete() + activities = self.video.get_activities() + self.assertEqual(0, len(activities), 'Should be no activities') + + def tearDown(self) -> None: + for i in ["tmp5.mp4"]: + if os.path.exists(i): + os.remove(i) + projects = self.h.get_projects() + for p in projects: + p.delete() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_video.py b/tests/test_video.py new file mode 100644 index 0000000..f415d1a --- /dev/null +++ b/tests/test_video.py @@ -0,0 +1,100 @@ +import os +import time +import unittest +import urllib.request + +from hasty.exception import ValidationException +from hasty.constants import ProjectType +from tests.utils import get_client, vid_url + + +class TestVideo(unittest.TestCase): + def setUp(self): + self.h = get_client() + ws = self.h.get_workspaces() + ws0 = ws[0] + self.project = self.h.create_project(ws0, "Test Project 1", content_type=ProjectType.Video) + urllib.request.urlretrieve(vid_url, "video.mp4") + + def test_video(self): + ds = self.project.create_dataset("ds") + # Test upload from file + self.project.upload_from_file(ds, "video.mp4") + videos = self.project.get_videos() + self.assertEqual(1, len(videos)) + self.assertEqual("video.mp4", videos[0].name) + # Upload from url + self.project.upload_from_url(ds, "tmp1.mp4", vid_url) + videos = self.project.get_videos() + self.assertEqual(2, len(videos)) + self.assertIn(videos[0].name, ("video.mp4", "tmp1.mp4")) + self.assertIn(videos[1].name, ("video.mp4", "tmp1.mp4")) + # Download video + for _ in range(60): # timeout in 5-mins + try: + videos[0].download(videos[0].name) + break + except ValidationException: + time.sleep(5) + continue + self.assertTrue(os.path.exists(videos[0].name), "video doesnt exists after download") + # Delete dataset + ds.delete() + + def test_video_filters_and_status(self): + ds2 = self.project.create_dataset("ds2") + self.project.upload_from_url(ds2, "tmp1.mp4", vid_url) + vid2 = self.project.upload_from_url(ds2, "tmp2.mp4", vid_url) + ds3 = self.project.create_dataset("ds3") + vid3 = self.project.upload_from_url(ds3, "tmp3.mp4", vid_url) + vid2.set_status("TO REVIEW") + vid3.set_status("DONE") + # Check total number of videos + videos = self.project.get_videos() + self.assertEqual(3, len(videos)) + # Filter by status DONE + videos = self.project.get_videos(video_status="DONE") + self.assertEqual(1, len(videos)) + # Filter by status DONE, TO REVIEW + videos = self.project.get_videos(video_status=["DONE", "TO REVIEW"]) + self.assertEqual(2, len(videos)) + # Filter by status and dataset + videos = self.project.get_videos(dataset=ds3, video_status=["DONE", "TO REVIEW"]) + self.assertEqual(1, len(videos)) + # Filter by datasets + videos = self.project.get_videos(dataset=[ds2, ds3]) + self.assertEqual(3, len(videos)) + # Delete dataset + ds2.delete() + ds3.delete() + + def test_video_rename_move_delete(self): + ds2 = self.project.create_dataset("ds2") + ds3 = self.project.create_dataset("ds3") + self.project.upload_from_url(ds2, "tmp1.mp4", vid_url) + # Check video name + videos = self.project.get_videos() + video = videos[0] + self.assertEqual("tmp1.mp4", video.name) + # Check rename feature + video.rename("tmp2.mp4") + self.assertEqual("tmp2.mp4", video.name) + # Check move feature + video.move(ds3) + self.assertEqual(ds3.id, video.dataset_id) + # Check delete + video.delete() + videos = self.project.get_videos() + self.assertEqual(0, len(videos)) + + def tearDown(self) -> None: + projects = self.h.get_projects() + for i in ["video.mp4", "tmp1.mp4"]: + if os.path.exists(i): + os.remove(i) + for p in projects: + p.delete() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils.py b/tests/utils.py index 7571631..9e047f1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,8 @@ img_url = "https://images.ctfassets.net/hiiv1w4761fl/6NhZFymLPiX8abIUuEYV7i/c7a63c3a56e7e4f40cfd459c01a10853" \ "/Untitled_presentation__6_.jpg?w=945&h=494&q=50&fit=fill" +vid_url = "https://storage.googleapis.com/hasty-test-fixtures/videos/WeAreGoingOnBullrun.mp4" + def get_client(): API_KEY = os.environ.get("HASTY_API_KEY")