diff --git a/.gitignore b/.gitignore index 7e458fc..896541e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,7 @@ wheels/ .env .DS_Store .claude - -# Test data tests/test_data download labellerr/__pycache__/ -env.dev \ No newline at end of file +env.dev diff --git a/README.md b/README.md index 6f6f78d..4f44b0e 100644 --- a/README.md +++ b/README.md @@ -420,10 +420,10 @@ The Labellerr SDK uses **class-level decorators** to automatically apply logging ### Benefits -✓ **No Boilerplate**: You don't need to add logging or error handling code in every method -✓ **Consistency**: All methods follow the same logging pattern -✓ **Maintainability**: Changes to logging or error handling are centralized -✓ **Debugging**: Comprehensive logs help troubleshoot issues quickly +**No Boilerplate**: You don't need to add logging or error handling code in every method +**Consistency**: All methods follow the same logging pattern +**Maintainability**: Changes to logging or error handling are centralized +**Debugging**: Comprehensive logs help troubleshoot issues quickly ### How It Works diff --git a/driver.py b/driver.py index f170eff..c59c96e 100644 --- a/driver.py +++ b/driver.py @@ -1,10 +1,9 @@ -from labellerr.client import LabellerrClient -from labellerr.core.datasets import LabellerrDataset +import os -# from labellerr.core.autolabel import LabellerrAutoLabel -# from labellerr.core.autolabel.typings import TrainingRequest from dotenv import load_dotenv -import os + +from labellerr.client import LabellerrClient +from labellerr.core.datasets import LabellerrDataset load_dotenv() diff --git a/labellerr/core/autolabel/base.py b/labellerr/core/autolabel/base.py index 74579eb..7a3d8bd 100644 --- a/labellerr/core/autolabel/base.py +++ b/labellerr/core/autolabel/base.py @@ -1,8 +1,12 @@ +import uuid from abc import ABCMeta -from ..client import LabellerrClient +from typing import TYPE_CHECKING + +from .. import client_utils, constants from .typings import TrainingRequest -from .. import constants, client_utils -import uuid + +if TYPE_CHECKING: + from ..client import LabellerrClient class LabellerrAutoLabelMeta(ABCMeta): @@ -10,7 +14,7 @@ class LabellerrAutoLabelMeta(ABCMeta): class LabellerrAutoLabel(metaclass=LabellerrAutoLabelMeta): - def __init__(self, client: LabellerrClient): + def __init__(self, client: "LabellerrClient"): self.client = client def train(self, training_request: TrainingRequest): diff --git a/labellerr/core/autolabel/typings.py b/labellerr/core/autolabel/typings.py index f07ff92..966fe87 100644 --- a/labellerr/core/autolabel/typings.py +++ b/labellerr/core/autolabel/typings.py @@ -1,5 +1,6 @@ +from typing import List, Optional + from pydantic import BaseModel -from typing import Optional class Hyperparameters(BaseModel): @@ -8,7 +9,7 @@ class Hyperparameters(BaseModel): class TrainingRequest(BaseModel): model_id: str - projects: Optional[list[str]] = None + projects: Optional[List[str]] = None hyperparameters: Optional[Hyperparameters] = Hyperparameters() slice_id: Optional[str] = None min_samples_per_class: Optional[int] = 100 diff --git a/labellerr/core/client.py b/labellerr/core/client.py index eef9247..61c171d 100644 --- a/labellerr/core/client.py +++ b/labellerr/core/client.py @@ -1,6 +1,5 @@ # labellerr/client.py -import concurrent.futures import json import logging import os @@ -13,9 +12,14 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from . import client_utils, schemas -from . import constants, gcs +from . import client_utils, constants, gcs, schemas + +# Initialize DataSets handler for dataset-related operations +from .datasets.datasets import DataSets from .exceptions import LabellerrError + +# Initialize Projects handler for project-related operations +from .projects.base import LabellerrProject from .utils import validate_params from .validators import auto_log_and_handle_errors @@ -98,10 +102,19 @@ def __init__( if enable_connection_pooling: self._setup_session() - # Initialize DataSets handler for dataset-related operations - from .datasets.datasets_legacy import Datasets + self.datasets = DataSets(api_key, api_secret, self) + + self.projects = LabellerrProject.__new__(LabellerrProject) + self.projects.api_key = api_key + self.projects.api_secret = api_secret + self.projects.client = self + + # Initialize Users handler for user-related operations + from .users.base import LabellerrUsers - self.datasets = Datasets(api_key, api_secret, self) + self.users = LabellerrUsers() + self.users.api_key = api_key + self.users.api_secret = api_secret def _setup_session(self): """ @@ -615,36 +628,6 @@ def get_dataset(self, workspace_id, dataset_id): return client_utils.request("GET", url, headers=headers) - def update_rotation_count(self): - """ - Updates the rotation count for a project. - - :return: A dictionary indicating the success of the operation. - """ - try: - unique_id = str(uuid.uuid4()) - url = f"{self.base_url}/projects/rotations/add?project_id={self.project_id}&client_id={self.client_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=self.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload = json.dumps(self.rotation_config) - logging.info(f"Update Rotation Count Payload: {payload}") - - response = requests.request("POST", url, headers=headers, data=payload) - - logging.info("Rotation configuration updated successfully.") - client_utils.handle_response(response, unique_id) - - return {"msg": "project rotation configuration updated"} - except LabellerrError as e: - logging.error(f"Project rotation update config failed: {e}") - raise - def _setup_cloud_connector( self, connector_type: str, client_id: str, connector_config: dict ): @@ -866,410 +849,6 @@ def get_total_file_count_and_total_size(self, files_list, data_type): return total_file_count, total_file_size, files_list - def get_all_project_per_client_id(self, client_id): - """ - Retrieves a list of projects associated with a client ID. - - :param client_id: The ID of the client. - :return: A dictionary containing the list of projects. - :raises LabellerrError: If the retrieval fails. - """ - try: - unique_id = str(uuid.uuid4()) - url = f"{self.base_url}/project_drafts/projects/detailed_list?client_id={client_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=client_id, - extra_headers={"content-type": "application/json"}, - ) - - response = requests.request("GET", url, headers=headers, data={}) - return client_utils.handle_response(response, unique_id) - except Exception as e: - logging.error(f"Failed to retrieve projects: {str(e)}") - raise - - def _upload_preannotation_sync( - self, project_id, client_id, annotation_format, annotation_file - ): - """ - Synchronous implementation of preannotation upload. - - :param project_id: The ID of the project. - :param client_id: The ID of the client. - :param annotation_format: The format of the preannotation data. - :param annotation_file: The file path of the preannotation data. - :return: The response from the API. - :raises LabellerrError: If the upload fails. - """ - try: - # validate all the parameters - required_params = { - "project_id": project_id, - "client_id": client_id, - "annotation_format": annotation_format, - "annotation_file": annotation_file, - } - client_utils.validate_required_params( - required_params, list(required_params.keys()) - ) - client_utils.validate_annotation_format(annotation_format, annotation_file) - - request_uuid = str(uuid.uuid4()) - url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" - file_name = client_utils.validate_file_exists(annotation_file) - # get the direct upload url - gcs_path = f"{project_id}/{annotation_format}-{file_name}" - logging.info("Uploading your file to Labellerr. Please wait...") - direct_upload_url = self.get_direct_upload_url(gcs_path, client_id) - # Now let's wait for the file to be uploaded to the gcs - gcs.upload_to_gcs_direct(direct_upload_url, annotation_file) - payload = {} - # with open(annotation_file, 'rb') as f: - # files = [ - # ('file', (file_name, f, 'application/octet-stream')) - # ] - # response = requests.request("POST", url, headers={ - # 'client_id': client_id, - # 'api_key': self.api_key, - # 'api_secret': self.api_secret, - # 'origin': constants.ALLOWED_ORIGINS, - # 'source':'sdk', - # 'email_id': self.api_key - # }, data=payload, files=files) - url += "&gcs_path=" + gcs_path - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=client_id, - extra_headers={"email_id": self.api_key}, - ) - response = requests.request("POST", url, headers=headers, data=payload) - response_data = self._handle_upload_response(response, request_uuid) - - # read job_id from the response - job_id = response_data["response"]["job_id"] - self.client_id = client_id - self.job_id = job_id - self.project_id = project_id - - logging.info(f"Preannotation upload successful. Job ID: {job_id}") - - # Use max_retries=10 with 5-second intervals = 50 seconds max (fits within typical test timeouts) - future = self.preannotation_job_status_async( - max_retries=10, retry_interval=5 - ) - return future.result() - except Exception as e: - logging.error(f"Failed to upload preannotation: {str(e)}") - raise - - def upload_preannotation_by_project_id_async( - self, project_id, client_id, annotation_format, annotation_file - ): - """ - Asynchronously uploads preannotation data to a project. - - :param project_id: The ID of the project. - :param client_id: The ID of the client. - :param annotation_format: The format of the preannotation data. - :param annotation_file: The file path of the preannotation data. - :return: A Future object that will contain the response from the API. - :raises LabellerrError: If the upload fails. - """ - - def upload_and_monitor(): - try: - # validate all the parameters - required_params = [ - "project_id", - "client_id", - "annotation_format", - "annotation_file", - ] - for param in required_params: - if param not in locals(): - raise LabellerrError(f"Required parameter {param} is missing") - - if annotation_format not in constants.ANNOTATION_FORMAT: - raise LabellerrError( - f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" - ) - - request_uuid = str(uuid.uuid4()) - url = ( - f"{self.base_url}/actions/upload_answers?" - f"project_id={project_id}&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" - ) - - # validate if the file exist then extract file name from the path - if os.path.exists(annotation_file): - file_name = os.path.basename(annotation_file) - else: - raise LabellerrError("File not found") - - # Check if the file extension is .json when annotation_format is coco_json - if annotation_format == "coco_json": - file_extension = os.path.splitext(annotation_file)[1].lower() - if file_extension != ".json": - raise LabellerrError( - "For coco_json annotation format, the file must have a .json extension" - ) - # get the direct upload url - gcs_path = f"{project_id}/{annotation_format}-{file_name}" - logging.info("Uploading your file to Labellerr. Please wait...") - direct_upload_url = self.get_direct_upload_url(gcs_path, client_id) - # Now let's wait for the file to be uploaded to the gcs - gcs.upload_to_gcs_direct(direct_upload_url, annotation_file) - payload = {} - # with open(annotation_file, 'rb') as f: - # files = [ - # ('file', (file_name, f, 'application/octet-stream')) - # ] - # response = requests.request("POST", url, headers={ - # 'client_id': client_id, - # 'api_key': self.api_key, - # 'api_secret': self.api_secret, - # 'origin': constants.ALLOWED_ORIGINS, - # 'source':'sdk', - # 'email_id': self.api_key - # }, data=payload, files=files) - url += "&gcs_path=" + gcs_path - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=client_id, - extra_headers={"email_id": self.api_key}, - ) - response = requests.request("POST", url, headers=headers, data=payload) - response_data = self._handle_upload_response(response, request_uuid) - - # read job_id from the response - job_id = response_data["response"]["job_id"] - self.client_id = client_id - self.job_id = job_id - self.project_id = project_id - - logging.info(f"Pre annotation upload successful. Job ID: {job_id}") - - # Now monitor the status - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=self.client_id, - extra_headers={"Origin": constants.ALLOWED_ORIGINS}, - ) - status_url = f"{self.base_url}/actions/upload_answers_status?project_id={self.project_id}&job_id={self.job_id}&client_id={self.client_id}" - while True: - try: - response = requests.request( - "GET", status_url, headers=headers, data={} - ) - status_data = response.json() - - logging.debug(f"Status data: {status_data}") - - # Check if job is completed - if status_data.get("response", {}).get("status") == "completed": - return status_data - - logging.info("Syncing status after 5 seconds . . .") - time.sleep(5) - - except Exception as e: - logging.error( - f"Failed to get preannotation job status: {str(e)}" - ) - raise - - except Exception as e: - logging.exception(f"Failed to upload preannotation: {str(e)}") - raise - - with concurrent.futures.ThreadPoolExecutor() as executor: - return executor.submit(upload_and_monitor) - - def preannotation_job_status_async(self, max_retries=60, retry_interval=5): - """ - Get the status of a preannotation job asynchronously with timeout protection. - - Args: - max_retries: Maximum number of retries before timing out (default: 60 retries = 5 minutes) - retry_interval: Seconds to wait between retries (default: 5 seconds) - - Returns: - concurrent.futures.Future: A future that will contain the final job status - - Raises: - LabellerrError: If max retries exceeded or job status check fails - """ - - def check_status(): - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=self.client_id, - extra_headers={"Origin": constants.ALLOWED_ORIGINS}, - ) - url = f"{self.base_url}/actions/upload_answers_status?project_id={self.project_id}&job_id={self.job_id}&client_id={self.client_id}" - payload = {} - retry_count = 0 - - while retry_count < max_retries: - try: - response = requests.request( - "GET", url, headers=headers, data=payload - ) - response_data = response.json() - - # Check if job is completed - if response_data.get("response", {}).get("status") == "completed": - logging.info( - f"Pre-annotation job completed after {retry_count} retries" - ) - return response_data - - retry_count += 1 - if retry_count < max_retries: - logging.info( - f"Retry {retry_count}/{max_retries}: Job not complete, retrying after {retry_interval} seconds..." - ) - time.sleep(retry_interval) - else: - # Max retries exceeded - total_wait_time = max_retries * retry_interval - raise LabellerrError( - f"Pre-annotation job did not complete after {max_retries} retries " - f"({total_wait_time} seconds). Job ID: {self.job_id}. " - f"Last status: {response_data.get('response', {}).get('status', 'unknown')}" - ) - - except LabellerrError: - # Re-raise LabellerrError without wrapping - raise - except Exception as e: - logging.error(f"Failed to get preannotation job status: {str(e)}") - raise LabellerrError( - f"Failed to get preannotation job status: {str(e)}" - ) - return None - - with concurrent.futures.ThreadPoolExecutor() as executor: - return executor.submit(check_status) - - def upload_preannotation_by_project_id( - self, project_id, client_id, annotation_format, annotation_file - ): - """ - Uploads preannotation data to a project. - - :param project_id: The ID of the project. - :param client_id: The ID of the client. - :param annotation_format: The format of the preannotation data. - :param annotation_file: The file path of the preannotation data. - :return: The response from the API. - :raises LabellerrError: If the upload fails. - """ - try: - # validate all the parameters - required_params = [ - "project_id", - "client_id", - "annotation_format", - "annotation_file", - ] - for param in required_params: - if param not in locals(): - raise LabellerrError(f"Required parameter {param} is missing") - - if annotation_format not in constants.ANNOTATION_FORMAT: - raise LabellerrError( - f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" - ) - - request_uuid = str(uuid.uuid4()) - url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" - - # validate if the file exist then extract file name from the path - if os.path.exists(annotation_file): - file_name = os.path.basename(annotation_file) - else: - raise LabellerrError("File not found") - - payload = {} - with open(annotation_file, "rb") as f: - files = [("file", (file_name, f, "application/octet-stream"))] - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=client_id, - extra_headers={"email_id": self.api_key}, - ) - response = requests.request( - "POST", url, headers=headers, data=payload, files=files - ) - response_data = self._handle_upload_response(response, request_uuid) - logging.debug(f"response_data: {response_data}") - - # read job_id from the response - job_id = response_data["response"]["job_id"] - self.client_id = client_id - self.job_id = job_id - self.project_id = project_id - - logging.info(f"Preannotation upload successful. Job ID: {job_id}") - - # Use max_retries=10 with 5-second intervals = 50 seconds max (fits within typical test timeouts) - future = self.preannotation_job_status_async( - max_retries=10, retry_interval=5 - ) - return future.result() - except Exception as e: - logging.error(f"Failed to upload preannotation: {str(e)}") - raise - - def create_local_export(self, project_id, client_id, export_config): - """ - Creates a local export with the given configuration. - - :param project_id: The ID of the project. - :param client_id: The ID of the client. - :param export_config: Export configuration dictionary. - :return: The response from the API. - :raises LabellerrError: If the export creation fails. - """ - # Validate parameters using Pydantic - schemas.CreateLocalExportParams( - project_id=project_id, - client_id=client_id, - export_config=export_config, - ) - # Validate export config using client_utils - client_utils.validate_export_config(export_config) - - unique_id = client_utils.generate_request_id() - export_config.update({"export_destination": "local", "question_ids": ["all"]}) - - payload = json.dumps(export_config) - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - extra_headers={ - "Origin": constants.ALLOWED_ORIGINS, - "Content-Type": "application/json", - }, - ) - - return client_utils.request( - "POST", - f"{self.base_url}/sdk/export/files?project_id={project_id}&client_id={client_id}", - headers=headers, - data=payload, - request_id=unique_id, - ) - def fetch_download_url(self, project_id, uuid, export_id, client_id): try: headers = client_utils.build_headers( @@ -1303,58 +882,6 @@ def fetch_download_url(self, project_id, uuid, export_id, client_id): logging.error(f"Unexpected error in download_function: {str(e)}") raise - @validate_params(project_id=str, report_ids=list, client_id=str) - def check_export_status( - self, project_id: str, report_ids: List[str], client_id: str - ): - request_uuid = client_utils.generate_request_id() - try: - if not project_id: - raise LabellerrError("project_id cannot be null") - if not report_ids: - raise LabellerrError("report_ids cannot be empty") - - # Construct URL - url = f"{constants.BASE_URL}/exports/status?project_id={project_id}&uuid={request_uuid}&client_id={client_id}" - - # Headers - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=client_id, - extra_headers={"Content-Type": "application/json"}, - ) - - payload = json.dumps({"report_ids": report_ids}) - - response = requests.post(url, headers=headers, data=payload) - result = client_utils.handle_response(response, request_uuid) - - # Now process each report_id - for status_item in result.get("status", []): - if ( - status_item.get("is_completed") - and status_item.get("export_status") == "Created" - ): - # Download URL if job completed - download_url = ( # noqa E999 todo check use of that - self.fetch_download_url( - project_id=project_id, - uuid=request_uuid, - export_id=status_item["report_id"], - client_id=client_id, - ) - ) - - return json.dumps(result, indent=2) - - except requests.exceptions.RequestException as e: - logging.error(f"Failed to check export status: {str(e)}") - raise - except Exception as e: - logging.error(f"Unexpected error checking export status: {str(e)}") - raise - def create_template(self, client_id, data_type, template_name, questions): """ Creates an annotation template with the given configuration. @@ -1394,445 +921,6 @@ def create_template(self, client_id, data_type, template_name, questions): "POST", url, headers=headers, data=payload, request_id=unique_id ) - def create_user( - self, - client_id, - first_name, - last_name, - email_id, - projects, - roles, - work_phone="", - job_title="", - language="en", - timezone="GMT", - ): - """ - Creates a new user in the system. - - :param client_id: The ID of the client - :param first_name: User's first name - :param last_name: User's last name - :param email_id: User's email address - :param projects: List of project IDs to assign the user to - :param roles: List of role objects with project_id and role_id - :param work_phone: User's work phone number (optional) - :param job_title: User's job title (optional) - :param language: User's preferred language (default: "en") - :param timezone: User's timezone (default: "GMT") - :return: Dictionary containing user creation response - :raises LabellerrError: If the creation fails - """ - # Validate parameters using Pydantic - params = schemas.CreateUserParams( - client_id=client_id, - first_name=first_name, - last_name=last_name, - email_id=email_id, - projects=projects, - roles=roles, - work_phone=work_phone, - job_title=job_title, - language=language, - timezone=timezone, - ) - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/register?client_id={params.client_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={ - "content-type": "application/json", - "accept": "application/json, text/plain, */*", - }, - ) - - payload = json.dumps( - { - "first_name": params.first_name, - "last_name": params.last_name, - "work_phone": params.work_phone, - "job_title": params.job_title, - "language": params.language, - "timezone": params.timezone, - "email_id": params.email_id, - "projects": params.projects, - "client_id": params.client_id, - "roles": params.roles, - } - ) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def update_user_role( - self, - client_id, - project_id, - email_id, - roles, - first_name=None, - last_name=None, - work_phone="", - job_title="", - language="en", - timezone="GMT", - profile_image="", - ): - """ - Updates a user's role and profile information. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param email_id: User's email address - :param roles: List of role objects with project_id and role_id - :param first_name: User's first name (optional) - :param last_name: User's last name (optional) - :param work_phone: User's work phone number (optional) - :param job_title: User's job title (optional) - :param language: User's preferred language (default: "en") - :param timezone: User's timezone (default: "GMT") - :param profile_image: User's profile image (optional) - :return: Dictionary containing update response - :raises LabellerrError: If the update fails - """ - # Validate parameters using Pydantic - params = schemas.UpdateUserRoleParams( - client_id=client_id, - project_id=project_id, - email_id=email_id, - roles=roles, - first_name=first_name, - last_name=last_name, - work_phone=work_phone, - job_title=job_title, - language=language, - timezone=timezone, - profile_image=profile_image, - ) - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/update?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={ - "content-type": "application/json", - "accept": "application/json, text/plain, */*", - }, - ) - - # Build the payload with all provided information - # Extract project_ids from roles for API requirement - project_ids = [ - role.get("project_id") for role in params.roles if "project_id" in role - ] - - payload_data = { - "profile_image": params.profile_image, - "work_phone": params.work_phone, - "job_title": params.job_title, - "language": params.language, - "timezone": params.timezone, - "email_id": params.email_id, - "client_id": params.client_id, - "roles": params.roles, - "projects": project_ids, # API requires projects list extracted from roles (same format as create_user) - } - - # Add optional fields if provided - if params.first_name is not None: - payload_data["first_name"] = params.first_name - if params.last_name is not None: - payload_data["last_name"] = params.last_name - - payload = json.dumps(payload_data) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def delete_user( - self, - client_id, - project_id, - email_id, - user_id, - first_name=None, - last_name=None, - is_active=1, - role="Annotator", - user_created_at=None, - max_activity_created_at=None, - image_url="", - name=None, - activity="No Activity", - creation_date=None, - status="Activated", - ): - """ - Deletes a user from the system. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param email_id: User's email address - :param user_id: User's unique identifier - :param first_name: User's first name (optional) - :param last_name: User's last name (optional) - :param is_active: User's active status (default: 1) - :param role: User's role (default: "Annotator") - :param user_created_at: User creation timestamp (optional) - :param max_activity_created_at: Max activity timestamp (optional) - :param image_url: User's profile image URL (optional) - :param name: User's display name (optional) - :param activity: User's activity status (default: "No Activity") - :param creation_date: User creation date (optional) - :param status: User's status (default: "Activated") - :return: Dictionary containing deletion response - :raises LabellerrError: If the deletion fails - """ - # Validate parameters using Pydantic - params = schemas.DeleteUserParams( - client_id=client_id, - project_id=project_id, - email_id=email_id, - user_id=user_id, - first_name=first_name, - last_name=last_name, - is_active=is_active, - role=role, - user_created_at=user_created_at, - max_activity_created_at=max_activity_created_at, - image_url=image_url, - name=name, - activity=activity, - creation_date=creation_date, - status=status, - ) - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/delete?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={ - "content-type": "application/json", - "accept": "application/json, text/plain, */*", - }, - ) - - # Build the payload with all provided information - payload_data = { - "email_id": params.email_id, - "is_active": params.is_active, - "role": params.role, - "user_id": params.user_id, - "imageUrl": params.image_url, - "email": params.email_id, - "activity": params.activity, - "status": params.status, - } - - # Add optional fields if provided - if params.first_name is not None: - payload_data["first_name"] = params.first_name - if params.last_name is not None: - payload_data["last_name"] = params.last_name - if params.user_created_at is not None: - payload_data["user_created_at"] = params.user_created_at - if params.max_activity_created_at is not None: - payload_data["max_activity_created_at"] = params.max_activity_created_at - if params.name is not None: - payload_data["name"] = params.name - if params.creation_date is not None: - payload_data["creationDate"] = params.creation_date - - payload = json.dumps(payload_data) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def add_user_to_project(self, client_id, project_id, email_id, role_id=None): - """ - Adds a user to a project. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param email_id: User's email address - :param role_id: Optional role ID to assign to the user - :return: Dictionary containing addition response - :raises LabellerrError: If the addition fails - """ - # Validate parameters using Pydantic - params = schemas.AddUserToProjectParams( - client_id=client_id, - project_id=project_id, - email_id=email_id, - role_id=role_id, - ) - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/add_user_to_project?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload_data = {"email_id": params.email_id, "uuid": unique_id} - - if params.role_id is not None: - payload_data["role_id"] = params.role_id - - payload = json.dumps(payload_data) - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def remove_user_from_project(self, client_id, project_id, email_id): - """ - Removes a user from a project. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param email_id: User's email address - :return: Dictionary containing removal response - :raises LabellerrError: If the removal fails - """ - # Validate parameters using Pydantic - params = schemas.RemoveUserFromProjectParams( - client_id=client_id, project_id=project_id, email_id=email_id - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/remove_user_from_project?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload_data = {"email_id": params.email_id, "uuid": unique_id} - - payload = json.dumps(payload_data) - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - # TODO: this is not working from UI - def change_user_role(self, client_id, project_id, email_id, new_role_id): - """ - Changes a user's role in a project. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param email_id: User's email address - :param new_role_id: The new role ID to assign to the user - :return: Dictionary containing role change response - :raises LabellerrError: If the role change fails - """ - # Validate parameters using Pydantic - params = schemas.ChangeUserRoleParams( - client_id=client_id, - project_id=project_id, - email_id=email_id, - new_role_id=new_role_id, - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/users/change_user_role?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload_data = { - "email_id": params.email_id, - "new_role_id": params.new_role_id, - "uuid": unique_id, - } - - payload = json.dumps(payload_data) - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def list_file( - self, client_id, project_id, search_queries, size=10, next_search_after=None - ): - # Validate parameters using Pydantic - params = schemas.ListFileParams( - client_id=client_id, - project_id=project_id, - search_queries=search_queries, - size=size, - next_search_after=next_search_after, - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/search/project_files?project_id={params.project_id}&client_id={params.client_id}&uuid={unique_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload = json.dumps( - { - "search_queries": params.search_queries, - "size": params.size, - "next_search_after": params.next_search_after, - } - ) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def bulk_assign_files(self, client_id, project_id, file_ids, new_status): - # Validate parameters using Pydantic - params = schemas.BulkAssignFilesParams( - client_id=client_id, - project_id=project_id, - file_ids=file_ids, - new_status=new_status, - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/actions/files/bulk_assign?project_id={params.project_id}&uuid={unique_id}&client_id={params.client_id}" - - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload = json.dumps( - { - "file_ids": params.file_ids, - "new_status": params.new_status, - } - ) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - @validate_params(client_id=str, project_id=str, file_id=str, key_frames=list) def link_key_frame( self, client_id: str, project_id: str, file_id: str, key_frames: List[KeyFrame] @@ -1901,39 +989,6 @@ def delete_key_frames(self, client_id: str, project_id: str): # ===== Dataset-related methods (delegated to DataSets) ===== - def create_project( - self, - project_name, - data_type, - client_id, - attached_datasets, - annotation_template_id, - rotations, - use_ai=False, - created_by=None, - ): - """ - Creates a project with the given configuration. - Delegates to the DataSets handler. - """ - return self.datasets.create_project( - project_name, - data_type, - client_id, - attached_datasets, - annotation_template_id, - rotations, - use_ai, - created_by, - ) - - def initiate_create_project(self, payload): - """ - Orchestrates project creation by handling dataset creation, annotation guidelines, - and final project setup. Delegates to the DataSets handler. - """ - return self.datasets.initiate_create_project(payload) - def create_annotation_guideline( self, client_id, questions, template_name, data_type ): @@ -1952,77 +1007,148 @@ def validate_rotation_config(self, rotation_config): """ return self.datasets.validate_rotation_config(rotation_config) - def create_dataset( + def sync_datasets( self, - dataset_config, - files_to_upload=None, - folder_to_upload=None, - connector_config=None, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, ): """ - Creates a dataset with support for multiple data types and connectors. + Syncs datasets from cloud storage (AWS S3 or GCS) to the Labellerr platform. Delegates to the DataSets handler. """ - return self.datasets.create_dataset( - dataset_config, files_to_upload, folder_to_upload, connector_config + return self.datasets.sync_datasets( + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, ) - def delete_dataset(self, client_id, dataset_id): + # ===== Project-related methods (delegated to Projects) ===== + + def initiate_create_project(self, payload): """ - Deletes a dataset from the system. - Delegates to the DataSets handler. + Orchestrates project creation by handling dataset creation, annotation guidelines, + and final project setup. + Delegates to the Projects handler. """ - return self.datasets.delete_dataset(client_id, dataset_id) + return self.projects.initiate_create_project(payload) - def upload_folder_files_to_dataset(self, data_config): + def list_file( + self, client_id, project_id, search_queries, size=10, next_search_after=None + ): """ - Uploads local files from a folder to a dataset using parallel processing. - Delegates to the DataSets handler. + Lists files in a project with optional filtering and pagination. + Delegates to the Projects handler. """ - return self.datasets.upload_folder_files_to_dataset(data_config) + return self.projects.list_file( + client_id, project_id, search_queries, size, next_search_after + ) - def initiate_attach_dataset_to_project(self, client_id, project_id, dataset_id): + def bulk_assign_files(self, client_id, project_id, file_ids, new_status): """ - Orchestrates attaching a dataset to a project. - Delegates to the DataSets handler. + Bulk assigns status to multiple files in a project. + Delegates to the Projects handler. """ - return self.datasets.attach_dataset_to_project( - client_id, project_id, dataset_id=dataset_id + return self.projects.bulk_assign_files( + client_id, project_id, file_ids, new_status ) - def initiate_attach_datasets_to_project(self, client_id, project_id, dataset_ids): - """ - Orchestrates attaching multiple datasets to a project (batch operation). - Delegates to the DataSets handler. + # ===== User-related methods (delegated to Users) ===== - :param client_id: The ID of the client - :param project_id: The ID of the project - :param dataset_ids: List of dataset IDs to attach - :return: Dictionary containing attachment status + def create_user( + self, + client_id, + first_name, + last_name, + email_id, + projects, + roles, + work_phone="", + job_title="", + language="en", + timezone="GMT", + ): + """ + Creates a new user in the system. + Delegates to the Users handler. """ - return self.datasets.attach_dataset_to_project( - client_id, project_id, dataset_ids=dataset_ids + return self.users.create_user( + client_id, + first_name, + last_name, + email_id, + projects, + roles, + work_phone, + job_title, + language, + timezone, ) - def initiate_detach_dataset_from_project(self, client_id, project_id, dataset_id): + def update_user_role( + self, + client_id, + project_id, + email_id, + roles, + first_name=None, + last_name=None, + work_phone="", + job_title="", + language="en", + timezone="GMT", + profile_image="", + ): """ - Orchestrates detaching a dataset from a project. - Delegates to the DataSets handler. + Updates a user's role and profile information. + Delegates to the Users handler. """ - return self.datasets.detach_dataset_from_project( - client_id, project_id, dataset_id=dataset_id + return self.users.update_user_role( + client_id, + project_id, + email_id, + roles, + first_name, + last_name, + work_phone, + job_title, + language, + timezone, + profile_image, ) - def initiate_detach_datasets_from_project(self, client_id, project_id, dataset_ids): + def delete_user(self, client_id, project_id, email_id, user_id): """ - Orchestrates detaching multiple datasets from a project (batch operation). - Delegates to the DataSets handler. + Deletes a user from the system. + Delegates to the Users handler. + """ + return self.users.delete_user(client_id, project_id, email_id, user_id) - :param client_id: The ID of the client - :param project_id: The ID of the project - :param dataset_ids: List of dataset IDs to detach - :return: Dictionary containing detachment status + def add_user_to_project(self, client_id, project_id, email_id, role_id=None): """ - return self.datasets.detach_dataset_from_project( - client_id, project_id, dataset_ids=dataset_ids - ) + Adds a user to a project. + Delegates to the Users handler. + """ + return self.users.add_user_to_project(client_id, project_id, email_id, role_id) + + def remove_user_from_project(self, client_id, project_id, email_id): + """ + Removes a user from a project. + Delegates to the Users handler. + """ + return self.users.remove_user_from_project(client_id, project_id, email_id) + + def change_user_role(self, client_id, project_id, email_id, new_role_id): + """ + Changes a user's role in a project. + Delegates to the Users handler. + """ + return self.users.change_user_role(client_id, project_id, email_id, new_role_id) diff --git a/labellerr/core/connectors/connections.py b/labellerr/core/connectors/connections.py index db80f3a..2546801 100644 --- a/labellerr/core/connectors/connections.py +++ b/labellerr/core/connectors/connections.py @@ -1,15 +1,20 @@ -"""This module will contain all CRUD for connections. Example, create, list connections, get connection, delete connection, update connection, etc.""" +"""This module will contain all CRUD for connections. Example, create, list connections, get connection, delete connection, update connection, etc. +""" -from abc import ABCMeta, abstractmethod -from ..client import LabellerrClient -from .. import constants, client_utils -from ..exceptions import InvalidConnectionError import uuid +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Dict + +from .. import client_utils, constants +from ..exceptions import InvalidConnectionError, InvalidDatasetIDError + +if TYPE_CHECKING: + from ..client import LabellerrClient class LabellerrConnectionMeta(ABCMeta): # Class-level registry for connection types - _registry = {} + _registry: Dict[str, type] = {} @classmethod def register(cls, connection_type, connection_class): @@ -17,7 +22,7 @@ def register(cls, connection_type, connection_class): cls._registry[connection_type] = connection_class @staticmethod - def get_connection(client: LabellerrClient, connection_id: str): + def get_connection(client: "LabellerrClient", connection_id: str): """Get connection from Labellerr API""" # ------------------------------- [needs refactoring after we consolidate api_calls into one function ] --------------------------------- unique_id = str(uuid.uuid4()) @@ -50,7 +55,7 @@ def __call__(cls, client, connection_id, **kwargs): return instance connection_data = cls.get_connection(client, connection_id) if connection_data is None: - raise InvalidConnectionError(f"Connection not found: {connection_id}") + raise InvalidDatasetIDError(f"Connection not found: {connection_id}") connection_type = connection_data.get("connection_type") if connection_type not in constants.CONNECTION_TYPES: raise InvalidConnectionError( @@ -67,7 +72,7 @@ def __call__(cls, client, connection_id, **kwargs): class LabellerrConnection(metaclass=LabellerrConnectionMeta): """Base class for all Labellerr connections with factory behavior""" - def __init__(self, client: LabellerrClient, connection_id: str, **kwargs): + def __init__(self, client: "LabellerrClient", connection_id: str, **kwargs): self.client = client self.connection_id = connection_id self.connection_data = kwargs["connection_data"] diff --git a/labellerr/core/datasets/base.py b/labellerr/core/datasets/base.py index 22ba5d8..53c08e6 100644 --- a/labellerr/core/datasets/base.py +++ b/labellerr/core/datasets/base.py @@ -1,13 +1,21 @@ """This module will contain all CRUD for datasets. Example, create, list datasets, get dataset, delete dataset, update dataset, etc.""" -from abc import ABCMeta, abstractmethod -from ..client import LabellerrClient -from .. import constants, client_utils -from ..exceptions import InvalidDatasetError -import uuid -from ..exceptions import LabellerrError import json import logging +import os +import uuid +from abc import ABCMeta, abstractmethod +from asyncio import as_completed +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING + +from ... import schemas +from .. import client_utils, constants +from ..exceptions import InvalidDatasetError, LabellerrError +from ..utils import validate_params + +if TYPE_CHECKING: + from ..client import LabellerrClient class LabellerrDatasetMeta(ABCMeta): @@ -20,7 +28,7 @@ def register(cls, data_type, dataset_class): cls._registry[data_type] = dataset_class @staticmethod - def get_dataset(client: LabellerrClient, dataset_id: str): + def get_dataset(client: "LabellerrClient", dataset_id: str): """Get dataset from Labellerr API""" # ------------------------------- [needs refactoring after we consolidate api_calls into one function ] --------------------------------- unique_id = str(uuid.uuid4()) @@ -68,7 +76,7 @@ def __call__(cls, client, dataset_id, **kwargs): class LabellerrDataset(metaclass=LabellerrDatasetMeta): """Base class for all Labellerr files with factory behavior""" - def __init__(self, client: LabellerrClient, dataset_id: str, **kwargs): + def __init__(self, client: "LabellerrClient", dataset_id: str, **kwargs): self.client = client self.dataset_id = dataset_id self.dataset_data = kwargs["dataset_data"] @@ -82,6 +90,148 @@ def fetch_files(self): """Each file type must implement its own download logic""" pass + def attach_dataset_to_project( + self, client_id, project_id, dataset_id=None, dataset_ids=None + ): + """ + Attaches one or more datasets to an existing project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of a single dataset to attach (for backward compatibility) + :param dataset_ids: List of dataset IDs to attach (for batch operations) + :return: Dictionary containing attachment status + :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided + """ + # Handle both single and batch operations + if dataset_id is None and dataset_ids is None: + raise LabellerrError("Either dataset_id or dataset_ids must be provided") + + if dataset_id is not None and dataset_ids is not None: + raise LabellerrError( + "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." + ) + + # Convert single dataset_id to list for uniform processing + if dataset_id is not None: + dataset_ids = [dataset_id] + + # Validate parameters using Pydantic for each dataset + validated_dataset_ids = [] + for ds_id in dataset_ids: + params = schemas.AttachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=ds_id + ) + validated_dataset_ids.append(str(params.dataset_id)) + + # Use the first params validation for client_id and project_id + params = schemas.AttachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/jobs/add_datasets_to_project?project_id={params.project_id}&uuid={unique_id}&client_id={params.client_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps({"attached_datasets": validated_dataset_ids}) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def detach_dataset_from_project( + self, client_id, project_id, dataset_id=None, dataset_ids=None + ): + """ + Detaches one or more datasets from an existing project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of a single dataset to detach (for backward compatibility) + :param dataset_ids: List of dataset IDs to detach (for batch operations) + :return: Dictionary containing detachment status + :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided + """ + # Handle both single and batch operations + if dataset_id is None and dataset_ids is None: + raise LabellerrError("Either dataset_id or dataset_ids must be provided") + + if dataset_id is not None and dataset_ids is not None: + raise LabellerrError( + "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." + ) + + # Convert single dataset_id to list for uniform processing + if dataset_id is not None: + dataset_ids = [dataset_id] + + # Validate parameters using Pydantic for each dataset + validated_dataset_ids = [] + for ds_id in dataset_ids: + params = schemas.DetachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=ds_id + ) + validated_dataset_ids.append(str(params.dataset_id)) + + # Use the first params validation for client_id and project_id + params = schemas.DetachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/jobs/delete_datasets_from_project?project_id={params.project_id}&uuid={unique_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps({"attached_datasets": validated_dataset_ids}) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + @validate_params(client_id=str, datatype=str, project_id=str, scope=str) + def get_all_datasets( + self, client_id: str, datatype: str, project_id: str, scope: str + ): + """ + Retrieves datasets by parameters. + + :param client_id: The ID of the client. + :param datatype: The type of data for the dataset. + :param project_id: The ID of the project. + :param scope: The permission scope for the dataset. + :return: The dataset list as JSON. + """ + # Validate parameters using Pydantic + params = schemas.GetAllDatasetParams( + client_id=client_id, + datatype=datatype, + project_id=project_id, + scope=scope, + ) + unique_id = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/datasets/list?client_id={params.client_id}&data_type={params.datatype}&permission_level={params.scope}" + f"&project_id={params.project_id}&uuid={unique_id}" + ) + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request("GET", url, headers=headers, request_id=unique_id) + def create_dataset( self, dataset_config, @@ -200,3 +350,180 @@ def create_dataset( except LabellerrError as e: logging.error(f"Failed to create dataset: {e}") raise + + def delete_dataset(self, client_id, dataset_id): + """ + Deletes a dataset from the system. + + :param client_id: The ID of the client + :param dataset_id: The ID of the dataset to delete + :return: Dictionary containing deletion status + :raises LabellerrError: If the deletion fails + """ + # Validate parameters using Pydantic + params = schemas.DeleteDatasetParams(client_id=client_id, dataset_id=dataset_id) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/datasets/{params.dataset_id}/delete?client_id={params.client_id}&uuid={unique_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request( + "DELETE", url, headers=headers, request_id=unique_id + ) + + def upload_folder_files_to_dataset(self, data_config): + """ + Uploads local files from a folder to a dataset using parallel processing. + + :param data_config: A dictionary containing the configuration for the data. + :return: A dictionary containing the response status and the list of successfully uploaded files. + :raises LabellerrError: If there are issues with file limits, permissions, or upload process + """ + try: + # Validate required fields in data_config + required_fields = ["client_id", "folder_path", "data_type"] + missing_fields = [ + field for field in required_fields if field not in data_config + ] + if missing_fields: + raise LabellerrError( + f"Missing required fields in data_config: {', '.join(missing_fields)}" + ) + + # Validate folder path exists and is accessible + if not os.path.exists(data_config["folder_path"]): + raise LabellerrError( + f"Folder path does not exist: {data_config['folder_path']}" + ) + if not os.path.isdir(data_config["folder_path"]): + raise LabellerrError( + f"Path is not a directory: {data_config['folder_path']}" + ) + if not os.access(data_config["folder_path"], os.R_OK): + raise LabellerrError( + f"No read permission for folder: {data_config['folder_path']}" + ) + + success_queue = [] + fail_queue = [] + + try: + # Get files from folder + total_file_count, total_file_volumn, filenames = ( + self.client.get_total_folder_file_count_and_total_size( + data_config["folder_path"], data_config["data_type"] + ) + ) + except Exception as e: + logging.error(f"Failed to analyze folder contents: {str(e)}") + raise + + # Check file limits + if total_file_count > constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET: + raise LabellerrError( + f"Total file count: {total_file_count} exceeds limit of {constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET} files" + ) + if total_file_volumn > constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET: + raise LabellerrError( + f"Total file size: {total_file_volumn/1024/1024:.1f}MB exceeds limit of {constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET/1024/1024:.1f}MB" + ) + + logging.info(f"Total file count: {total_file_count}") + logging.info(f"Total file size: {total_file_volumn/1024/1024:.1f} MB") + + # Use generator for memory-efficient batch creation + def create_batches(): + current_batch = [] + current_batch_size = 0 + + for file_path in filenames: + try: + file_size = os.path.getsize(file_path) + if ( + current_batch_size + file_size > constants.FILE_BATCH_SIZE + or len(current_batch) >= constants.FILE_BATCH_COUNT + ): + if current_batch: + yield current_batch + current_batch = [file_path] + current_batch_size = file_size + else: + current_batch.append(file_path) + current_batch_size += file_size + except OSError as e: + logging.error(f"Error accessing file {file_path}: {str(e)}") + fail_queue.append(file_path) + except Exception as e: + logging.error( + f"Unexpected error processing {file_path}: {str(e)}" + ) + fail_queue.append(file_path) + + if current_batch: + yield current_batch + + # Convert generator to list for ThreadPoolExecutor + batches = list(create_batches()) + + if not batches: + raise LabellerrError( + "No valid files found to upload in the specified folder" + ) + + logging.info(f"CPU count: {os.cpu_count()}, Batch Count: {len(batches)}") + + # Calculate optimal number of workers based on CPU count and batch count + max_workers = min( + os.cpu_count(), # Number of CPU cores + len(batches), # Number of batches + 20, + ) + connection_id = str(uuid.uuid4()) + # Process batches in parallel + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_batch = { + executor.submit( + self.__process_batch, + data_config["client_id"], + batch, + connection_id, + ): batch + for batch in batches + } + + for future in as_completed(future_to_batch): + batch = future_to_batch[future] + try: + result = future.result() + if ( + isinstance(result, dict) + and result.get("message") == "200: Success" + ): + success_queue.extend(batch) + else: + fail_queue.extend(batch) + except Exception as e: + logging.exception(e) + logging.error(f"Batch upload failed: {str(e)}") + fail_queue.extend(batch) + + if not success_queue and fail_queue: + raise LabellerrError( + "All file uploads failed. Check individual file errors above." + ) + + return { + "connection_id": connection_id, + "success": success_queue, + "fail": fail_queue, + } + + except LabellerrError: + raise + except Exception as e: + logging.error(f"Failed to upload files: {str(e)}") + raise diff --git a/labellerr/core/datasets/datasets.py b/labellerr/core/datasets/datasets.py new file mode 100644 index 0000000..5e7f1a0 --- /dev/null +++ b/labellerr/core/datasets/datasets.py @@ -0,0 +1,824 @@ +import json +import logging +import os +import uuid +from asyncio import as_completed +from concurrent.futures import ThreadPoolExecutor + +import requests + +from labellerr.core import client_utils, constants, gcs, schemas, utils +from labellerr.core.exceptions import LabellerrError +from labellerr.core.utils import validate_params + + +class DataSets(object): + """ + Handles dataset-related operations for the Labellerr API. + """ + + def __init__(self, api_key, api_secret, client): + """ + Initialize the DataSets handler. + + :param api_key: The API key for authentication + :param api_secret: The API secret for authentication + :param client: Reference to the parent Labellerr Client instance for delegating certain operations + """ + self.api_key = api_key + self.api_secret = api_secret + self.client = client + + def create_project( + self, + project_name, + data_type, + client_id, + attached_datasets, + annotation_template_id, + rotations, + use_ai=False, + created_by=None, + ): + """ + Creates a project with the given configuration. + + :param project_name: Name of the project + :param data_type: Type of data (image, video, etc.) + :param client_id: ID of the client + :param attached_datasets: List of dataset IDs to attach to the project + :param annotation_template_id: ID of the annotation template + :param rotations: Dictionary containing rotation configuration + :param use_ai: Boolean flag for AI usage (default: False) + :param created_by: Optional creator information + :return: Project creation response + :raises LabellerrError: If the creation fails + """ + # Validate parameters using Pydantic + params = schemas.CreateProjectParams( + project_name=project_name, + data_type=data_type, + client_id=client_id, + attached_datasets=attached_datasets, + annotation_template_id=annotation_template_id, + rotations=rotations, + use_ai=use_ai, + created_by=created_by, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/projects/create?client_id={params.client_id}&uuid={unique_id}" + + payload = json.dumps( + { + "project_name": params.project_name, + "attached_datasets": params.attached_datasets, + "data_type": params.data_type, + "annotation_template_id": str(params.annotation_template_id), + "rotations": params.rotations.model_dump(), + "use_ai": params.use_ai, + "created_by": params.created_by, + } + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={ + "Origin": constants.ALLOWED_ORIGINS, + "Content-Type": "application/json", + }, + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def initiate_create_project(self, payload): + """ + Orchestrates project creation by handling dataset creation, annotation guidelines, + and final project setup. + """ + + try: + # validate all the parameters + required_params = [ + "client_id", + "dataset_name", + "dataset_description", + "data_type", + "created_by", + "project_name", + # Either annotation_guide or annotation_template_id must be provided + "autolabel", + ] + for param in required_params: + if param not in payload: + raise LabellerrError(f"Required parameter {param} is missing") + + if param == "client_id": + if ( + not isinstance(payload[param], str) + or not payload[param].strip() + ): + raise LabellerrError("client_id must be a non-empty string") + + # Validate created_by email format + created_by = payload.get("created_by") + if ( + not isinstance(created_by, str) + or "@" not in created_by + or "." not in created_by.split("@")[-1] + ): + raise LabellerrError("Please enter email id in created_by") + + # Ensure either annotation_guide or annotation_template_id is provided + if not payload.get("annotation_guide") and not payload.get( + "annotation_template_id" + ): + raise LabellerrError( + "Please provide either annotation guide or annotation template id" + ) + + # If annotation_guide is provided, validate its entries + if payload.get("annotation_guide"): + for guide in payload["annotation_guide"]: + if "option_type" not in guide: + raise LabellerrError( + "option_type is required in annotation_guide" + ) + if guide["option_type"] not in constants.OPTION_TYPE_LIST: + raise LabellerrError( + f"option_type must be one of {constants.OPTION_TYPE_LIST}" + ) + + if "folder_to_upload" in payload and "files_to_upload" in payload: + raise LabellerrError( + "Cannot provide both files_to_upload and folder_to_upload" + ) + + if "folder_to_upload" not in payload and "files_to_upload" not in payload: + raise LabellerrError( + "Either files_to_upload or folder_to_upload must be provided" + ) + + if ( + isinstance(payload.get("files_to_upload"), list) + and len(payload["files_to_upload"]) == 0 + ): + payload.pop("files_to_upload") + + if "rotation_config" not in payload: + payload["rotation_config"] = { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, + } + self.validate_rotation_config(payload["rotation_config"]) + + if payload["data_type"] not in constants.DATA_TYPES: + raise LabellerrError( + f"Invalid data_type. Must be one of {constants.DATA_TYPES}" + ) + + logging.info("Rotation configuration validated . . .") + + logging.info("Creating dataset . . .") + dataset_response = self.create_dataset( + { + "client_id": payload["client_id"], + "dataset_name": payload["dataset_name"], + "data_type": payload["data_type"], + "dataset_description": payload["dataset_description"], + }, + files_to_upload=payload.get("files_to_upload"), + folder_to_upload=payload.get("folder_to_upload"), + ) + + dataset_id = dataset_response["dataset_id"] + + def dataset_ready(): + try: + dataset_status = self.client.get_dataset( + payload["client_id"], dataset_id + ) + + if isinstance(dataset_status, dict): + + if "response" in dataset_status: + return ( + dataset_status["response"].get("status_code", 200) + == 300 + ) + else: + + return True + return False + except Exception as e: + logging.error(f"Error checking dataset status: {e}") + return False + + utils.poll( + function=dataset_ready, + condition=lambda x: x is True, + interval=5, + timeout=60, + ) + + logging.info("Dataset created and ready for use") + + if payload.get("annotation_template_id"): + annotation_template_id = payload["annotation_template_id"] + else: + annotation_template_id = self.create_annotation_guideline( + payload["client_id"], + payload["annotation_guide"], + payload["project_name"], + payload["data_type"], + ) + logging.info(f"Annotation guidelines created {annotation_template_id}") + + project_response = self.create_project( + project_name=payload["project_name"], + data_type=payload["data_type"], + client_id=payload["client_id"], + attached_datasets=[dataset_id], + annotation_template_id=annotation_template_id, + rotations=payload["rotation_config"], + use_ai=payload.get("use_ai", False), + created_by=payload["created_by"], + ) + + return { + "status": "success", + "message": "Project created successfully", + "project_id": project_response, + } + + except LabellerrError: + raise + except Exception: + logging.exception("Unexpected error in project creation") + raise + + def create_annotation_guideline( + self, client_id, questions, template_name, data_type + ): + """ + Updates the annotation guideline for a project. + + :param config: A dictionary containing the project ID, data type, client ID, autolabel status, and the annotation guideline. + :return: None + :raises LabellerrError: If the update fails. + """ + unique_id = str(uuid.uuid4()) + + url = f"{constants.BASE_URL}/annotations/create_template?data_type={data_type}&client_id={client_id}&uuid={unique_id}" + + guide_payload = json.dumps( + {"templateName": template_name, "questions": questions} + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=client_id, + extra_headers={"content-type": "application/json"}, + ) + + try: + response_data = client_utils.request( + "POST", url, headers=headers, data=guide_payload, request_id=unique_id + ) + return response_data["response"]["template_id"] + except requests.exceptions.RequestException as e: + logging.error(f"Failed to update project annotation guideline: {str(e)}") + raise + + def validate_rotation_config(self, rotation_config): + """ + Validates a rotation configuration. + + :param rotation_config: A dictionary containing the configuration for the rotations. + :raises LabellerrError: If the configuration is invalid. + """ + client_utils.validate_rotation_config(rotation_config) + + def create_dataset( + self, + dataset_config, + files_to_upload=None, + folder_to_upload=None, + connector_config=None, + ): + """ + Creates a dataset with support for multiple data types and connectors. + + :param dataset_config: A dictionary containing the configuration for the dataset. + Required fields: client_id, dataset_name, data_type + Optional fields: dataset_description, connector_type + :param files_to_upload: List of file paths to upload (for local connector) + :param folder_to_upload: Path to folder to upload (for local connector) + :param connector_config: Configuration for cloud connectors (GCP/AWS) + :return: A dictionary containing the response status and the ID of the created dataset. + """ + + try: + # Validate required fields + required_fields = ["client_id", "dataset_name", "data_type"] + for field in required_fields: + if field not in dataset_config: + raise LabellerrError( + f"Required field '{field}' missing in dataset_config" + ) + + # Validate data_type + if dataset_config.get("data_type") not in constants.DATA_TYPES: + raise LabellerrError( + f"Invalid data_type. Must be one of {constants.DATA_TYPES}" + ) + + connector_type = dataset_config.get("connector_type", "local") + connection_id = None + path = connector_type + + # Handle different connector types + if connector_type == "local": + if files_to_upload is not None: + try: + connection_id = self.client.upload_files( + client_id=dataset_config["client_id"], + files_list=files_to_upload, + ) + except Exception as e: + raise LabellerrError( + f"Failed to upload files to dataset: {str(e)}" + ) + + elif folder_to_upload is not None: + try: + result = self.upload_folder_files_to_dataset( + { + "client_id": dataset_config["client_id"], + "folder_path": folder_to_upload, + "data_type": dataset_config["data_type"], + } + ) + connection_id = result["connection_id"] + except Exception as e: + raise LabellerrError( + f"Failed to upload folder files to dataset: {str(e)}" + ) + elif connector_config is None: + # Create empty dataset for local connector + connection_id = None + + elif connector_type in ["gcp", "aws"]: + if connector_config is None: + raise LabellerrError( + f"connector_config is required for {connector_type} connector" + ) + + try: + connection_id = self.client._setup_cloud_connector( + connector_type, dataset_config["client_id"], connector_config + ) + except Exception as e: + raise LabellerrError( + f"Failed to setup {connector_type} connector: {str(e)}" + ) + else: + raise LabellerrError(f"Unsupported connector type: {connector_type}") + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/datasets/create?client_id={dataset_config['client_id']}&uuid={unique_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=dataset_config["client_id"], + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps( + { + "dataset_name": dataset_config["dataset_name"], + "dataset_description": dataset_config.get( + "dataset_description", "" + ), + "data_type": dataset_config["data_type"], + "connection_id": connection_id, + "path": path, + "client_id": dataset_config["client_id"], + "connector_type": connector_type, + } + ) + response_data = client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + dataset_id = response_data["response"]["dataset_id"] + + return {"response": "success", "dataset_id": dataset_id} + + except LabellerrError as e: + logging.error(f"Failed to create dataset: {e}") + raise + + def delete_dataset(self, client_id, dataset_id): + """ + Deletes a dataset from the system. + + :param client_id: The ID of the client + :param dataset_id: The ID of the dataset to delete + :return: Dictionary containing deletion status + :raises LabellerrError: If the deletion fails + """ + # Validate parameters using Pydantic + params = schemas.DeleteDatasetParams(client_id=client_id, dataset_id=dataset_id) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/datasets/{params.dataset_id}/delete?client_id={params.client_id}&uuid={unique_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request( + "DELETE", url, headers=headers, request_id=unique_id + ) + + def upload_folder_files_to_dataset(self, data_config): + """ + Uploads local files from a folder to a dataset using parallel processing. + + :param data_config: A dictionary containing the configuration for the data. + :return: A dictionary containing the response status and the list of successfully uploaded files. + :raises LabellerrError: If there are issues with file limits, permissions, or upload process + """ + try: + # Validate required fields in data_config + required_fields = ["client_id", "folder_path", "data_type"] + missing_fields = [ + field for field in required_fields if field not in data_config + ] + if missing_fields: + raise LabellerrError( + f"Missing required fields in data_config: {', '.join(missing_fields)}" + ) + + # Validate folder path exists and is accessible + if not os.path.exists(data_config["folder_path"]): + raise LabellerrError( + f"Folder path does not exist: {data_config['folder_path']}" + ) + if not os.path.isdir(data_config["folder_path"]): + raise LabellerrError( + f"Path is not a directory: {data_config['folder_path']}" + ) + if not os.access(data_config["folder_path"], os.R_OK): + raise LabellerrError( + f"No read permission for folder: {data_config['folder_path']}" + ) + + success_queue = [] + fail_queue = [] + + try: + # Get files from folder + total_file_count, total_file_volumn, filenames = ( + self.client.get_total_folder_file_count_and_total_size( + data_config["folder_path"], data_config["data_type"] + ) + ) + except Exception as e: + logging.error(f"Failed to analyze folder contents: {str(e)}") + raise + + # Check file limits + if total_file_count > constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET: + raise LabellerrError( + f"Total file count: {total_file_count} exceeds limit of {constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET} files" + ) + if total_file_volumn > constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET: + raise LabellerrError( + f"Total file size: {total_file_volumn/1024/1024:.1f}MB exceeds limit of {constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET/1024/1024:.1f}MB" + ) + + logging.info(f"Total file count: {total_file_count}") + logging.info(f"Total file size: {total_file_volumn/1024/1024:.1f} MB") + + # Use generator for memory-efficient batch creation + def create_batches(): + current_batch = [] + current_batch_size = 0 + + for file_path in filenames: + try: + file_size = os.path.getsize(file_path) + if ( + current_batch_size + file_size > constants.FILE_BATCH_SIZE + or len(current_batch) >= constants.FILE_BATCH_COUNT + ): + if current_batch: + yield current_batch + current_batch = [file_path] + current_batch_size = file_size + else: + current_batch.append(file_path) + current_batch_size += file_size + except OSError as e: + logging.error(f"Error accessing file {file_path}: {str(e)}") + fail_queue.append(file_path) + except Exception as e: + logging.error( + f"Unexpected error processing {file_path}: {str(e)}" + ) + fail_queue.append(file_path) + + if current_batch: + yield current_batch + + # Convert generator to list for ThreadPoolExecutor + batches = list(create_batches()) + + if not batches: + raise LabellerrError( + "No valid files found to upload in the specified folder" + ) + + logging.info(f"CPU count: {os.cpu_count()}, Batch Count: {len(batches)}") + + # Calculate optimal number of workers based on CPU count and batch count + max_workers = min( + os.cpu_count(), # Number of CPU cores + len(batches), # Number of batches + 20, + ) + connection_id = str(uuid.uuid4()) + # Process batches in parallel + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_batch = { + executor.submit( + self.__process_batch, + data_config["client_id"], + batch, + connection_id, + ): batch + for batch in batches + } + + for future in as_completed(future_to_batch): + batch = future_to_batch[future] + try: + result = future.result() + if ( + isinstance(result, dict) + and result.get("message") == "200: Success" + ): + success_queue.extend(batch) + else: + fail_queue.extend(batch) + except Exception as e: + logging.exception(e) + logging.error(f"Batch upload failed: {str(e)}") + fail_queue.extend(batch) + + if not success_queue and fail_queue: + raise LabellerrError( + "All file uploads failed. Check individual file errors above." + ) + + return { + "connection_id": connection_id, + "success": success_queue, + "fail": fail_queue, + } + + except LabellerrError: + raise + except Exception as e: + logging.error(f"Failed to upload files: {str(e)}") + raise + + def __process_batch(self, client_id, files_list, connection_id=None): + """ + Processes a batch of files. + """ + # Prepare files for upload + files = {} + for file_path in files_list: + file_name = os.path.basename(file_path) + files[file_name] = file_path + + response = self.client.connect_local_files( + client_id, list(files.keys()), connection_id + ) + resumable_upload_links = response["response"]["resumable_upload_links"] + for file_name in resumable_upload_links.keys(): + gcs.upload_to_gcs_resumable( + resumable_upload_links[file_name], files[file_name] + ) + + return response + + def attach_dataset_to_project( + self, client_id, project_id, dataset_id=None, dataset_ids=None + ): + """ + Attaches one or more datasets to an existing project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of a single dataset to attach (for backward compatibility) + :param dataset_ids: List of dataset IDs to attach (for batch operations) + :return: Dictionary containing attachment status + :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided + """ + # Handle both single and batch operations + if dataset_id is None and dataset_ids is None: + raise LabellerrError("Either dataset_id or dataset_ids must be provided") + + if dataset_id is not None and dataset_ids is not None: + raise LabellerrError( + "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." + ) + + # Convert single dataset_id to list for uniform processing + if dataset_id is not None: + dataset_ids = [dataset_id] + + # Validate parameters using Pydantic for each dataset + validated_dataset_ids = [] + for ds_id in dataset_ids: + params = schemas.AttachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=ds_id + ) + validated_dataset_ids.append(str(params.dataset_id)) + + # Use the first params validation for client_id and project_id + params = schemas.AttachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/jobs/add_datasets_to_project?project_id={params.project_id}&uuid={unique_id}&client_id={params.client_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps({"attached_datasets": validated_dataset_ids}) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def detach_dataset_from_project( + self, client_id, project_id, dataset_id=None, dataset_ids=None + ): + """ + Detaches one or more datasets from an existing project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of a single dataset to detach (for backward compatibility) + :param dataset_ids: List of dataset IDs to detach (for batch operations) + :return: Dictionary containing detachment status + :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided + """ + # Handle both single and batch operations + if dataset_id is None and dataset_ids is None: + raise LabellerrError("Either dataset_id or dataset_ids must be provided") + + if dataset_id is not None and dataset_ids is not None: + raise LabellerrError( + "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." + ) + + # Convert single dataset_id to list for uniform processing + if dataset_id is not None: + dataset_ids = [dataset_id] + + # Validate parameters using Pydantic for each dataset + validated_dataset_ids = [] + for ds_id in dataset_ids: + params = schemas.DetachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=ds_id + ) + validated_dataset_ids.append(str(params.dataset_id)) + + # Use the first params validation for client_id and project_id + params = schemas.DetachDatasetParams( + client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/jobs/delete_datasets_from_project?project_id={params.project_id}&uuid={unique_id}" + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps({"attached_datasets": validated_dataset_ids}) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + @validate_params(client_id=str, datatype=str, project_id=str, scope=str) + def get_all_datasets( + self, client_id: str, datatype: str, project_id: str, scope: str + ): + """ + Retrieves datasets by parameters. + + :param client_id: The ID of the client. + :param datatype: The type of data for the dataset. + :param project_id: The ID of the project. + :param scope: The permission scope for the dataset. + :return: The dataset list as JSON. + """ + # Validate parameters using Pydantic + params = schemas.GetAllDatasetParams( + client_id=client_id, + datatype=datatype, + project_id=project_id, + scope=scope, + ) + unique_id = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/datasets/list?client_id={params.client_id}&data_type={params.datatype}&permission_level={params.scope}" + f"&project_id={params.project_id}&uuid={unique_id}" + ) + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request("GET", url, headers=headers, request_id=unique_id) + + def sync_datasets( + self, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, + ): + """ + Syncs datasets with the backend. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of the dataset to sync + :param path: The path to sync + :param data_type: Type of data (image, video, audio, document, text) + :param email_id: Email ID of the user + :param connection_id: The connection ID + :return: Dictionary containing sync status + :raises LabellerrError: If the sync fails + """ + # Validate parameters using Pydantic + params = schemas.SyncDataSetParams( + client_id=client_id, + project_id=project_id, + dataset_id=dataset_id, + path=path, + data_type=data_type, + email_id=email_id, + connection_id=connection_id, + ) + + unique_id = str(uuid.uuid4()) + url = f"https://api-gateway-722091373895.us-central1.run.app/connectors/datasets/sync?uuid={unique_id}&client_id={params.client_id}" + + payload = json.dumps( + { + "client_id": params.client_id, + "project_id": params.project_id, + "dataset_id": params.dataset_id, + "path": params.path, + "data_type": params.data_type, + "email_id": params.email_id, + "connection_id": params.connection_id, + } + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) diff --git a/labellerr/core/datasets/datasets_legacy.py b/labellerr/core/datasets/datasets_legacy.py index 842e865..950ae68 100644 --- a/labellerr/core/datasets/datasets_legacy.py +++ b/labellerr/core/datasets/datasets_legacy.py @@ -2,15 +2,11 @@ import logging import os import uuid -from asyncio import as_completed -from concurrent.futures import ThreadPoolExecutor import requests -from .. import client_utils, gcs, schemas, utils -from .. import constants +from .. import client_utils, constants, gcs, schemas, utils from ..exceptions import LabellerrError -from ..utils import validate_params class Datasets(object): @@ -296,14 +292,86 @@ def create_annotation_guideline( logging.error(f"Failed to update project annotation guideline: {str(e)}") raise - def validate_rotation_config(self, rotation_config): + def __process_batch(self, client_id, files_list, connection_id=None): + """ + Processes a batch of files. """ - Validates a rotation configuration. + # Prepare files for upload + files = {} + for file_path in files_list: + file_name = os.path.basename(file_path) + files[file_name] = file_path - :param rotation_config: A dictionary containing the configuration for the rotations. - :raises LabellerrError: If the configuration is invalid. + response = self.client.connect_local_files( + client_id, list(files.keys()), connection_id + ) + resumable_upload_links = response["response"]["resumable_upload_links"] + for file_name in resumable_upload_links.keys(): + gcs.upload_to_gcs_resumable( + resumable_upload_links[file_name], files[file_name] + ) + + return response + + def sync_datasets( + self, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, + ): + """ + Syncs datasets with the backend. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param dataset_id: The ID of the dataset to sync + :param path: The path to sync + :param data_type: Type of data (image, video, audio, document, text) + :param email_id: Email ID of the user + :param connection_id: The connection ID + :return: Dictionary containing sync status + :raises LabellerrError: If the sync fails """ - client_utils.validate_rotation_config(rotation_config) + # Validate parameters using Pydantic + params = schemas.SyncDataSetParams( + client_id=client_id, + project_id=project_id, + dataset_id=dataset_id, + path=path, + data_type=data_type, + email_id=email_id, + connection_id=connection_id, + ) + + unique_id = str(uuid.uuid4()) + url = f"https://api-gateway-722091373895.us-central1.run.app/connectors/datasets/sync?uuid={unique_id}&client_id={params.client_id}" + + payload = json.dumps( + { + "client_id": params.client_id, + "project_id": params.project_id, + "dataset_id": params.dataset_id, + "path": params.path, + "data_type": params.data_type, + "email_id": params.email_id, + "connection_id": params.connection_id, + } + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) def create_dataset( self, @@ -423,343 +491,3 @@ def create_dataset( except LabellerrError as e: logging.error(f"Failed to create dataset: {e}") raise - - def delete_dataset(self, client_id, dataset_id): - """ - Deletes a dataset from the system. - - :param client_id: The ID of the client - :param dataset_id: The ID of the dataset to delete - :return: Dictionary containing deletion status - :raises LabellerrError: If the deletion fails - """ - # Validate parameters using Pydantic - params = schemas.DeleteDatasetParams(client_id=client_id, dataset_id=dataset_id) - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/datasets/{params.dataset_id}/delete?client_id={params.client_id}&uuid={unique_id}" - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - return client_utils.request( - "DELETE", url, headers=headers, request_id=unique_id - ) - - def upload_folder_files_to_dataset(self, data_config): - """ - Uploads local files from a folder to a dataset using parallel processing. - - :param data_config: A dictionary containing the configuration for the data. - :return: A dictionary containing the response status and the list of successfully uploaded files. - :raises LabellerrError: If there are issues with file limits, permissions, or upload process - """ - try: - # Validate required fields in data_config - required_fields = ["client_id", "folder_path", "data_type"] - missing_fields = [ - field for field in required_fields if field not in data_config - ] - if missing_fields: - raise LabellerrError( - f"Missing required fields in data_config: {', '.join(missing_fields)}" - ) - - # Validate folder path exists and is accessible - if not os.path.exists(data_config["folder_path"]): - raise LabellerrError( - f"Folder path does not exist: {data_config['folder_path']}" - ) - if not os.path.isdir(data_config["folder_path"]): - raise LabellerrError( - f"Path is not a directory: {data_config['folder_path']}" - ) - if not os.access(data_config["folder_path"], os.R_OK): - raise LabellerrError( - f"No read permission for folder: {data_config['folder_path']}" - ) - - success_queue = [] - fail_queue = [] - - try: - # Get files from folder - total_file_count, total_file_volumn, filenames = ( - self.client.get_total_folder_file_count_and_total_size( - data_config["folder_path"], data_config["data_type"] - ) - ) - except Exception as e: - logging.error(f"Failed to analyze folder contents: {str(e)}") - raise - - # Check file limits - if total_file_count > constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET: - raise LabellerrError( - f"Total file count: {total_file_count} exceeds limit of {constants.TOTAL_FILES_COUNT_LIMIT_PER_DATASET} files" - ) - if total_file_volumn > constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET: - raise LabellerrError( - f"Total file size: {total_file_volumn/1024/1024:.1f}MB exceeds limit of {constants.TOTAL_FILES_SIZE_LIMIT_PER_DATASET/1024/1024:.1f}MB" - ) - - logging.info(f"Total file count: {total_file_count}") - logging.info(f"Total file size: {total_file_volumn/1024/1024:.1f} MB") - - # Use generator for memory-efficient batch creation - def create_batches(): - current_batch = [] - current_batch_size = 0 - - for file_path in filenames: - try: - file_size = os.path.getsize(file_path) - if ( - current_batch_size + file_size > constants.FILE_BATCH_SIZE - or len(current_batch) >= constants.FILE_BATCH_COUNT - ): - if current_batch: - yield current_batch - current_batch = [file_path] - current_batch_size = file_size - else: - current_batch.append(file_path) - current_batch_size += file_size - except OSError as e: - logging.error(f"Error accessing file {file_path}: {str(e)}") - fail_queue.append(file_path) - except Exception as e: - logging.error( - f"Unexpected error processing {file_path}: {str(e)}" - ) - fail_queue.append(file_path) - - if current_batch: - yield current_batch - - # Convert generator to list for ThreadPoolExecutor - batches = list(create_batches()) - - if not batches: - raise LabellerrError( - "No valid files found to upload in the specified folder" - ) - - logging.info(f"CPU count: {os.cpu_count()}, Batch Count: {len(batches)}") - - # Calculate optimal number of workers based on CPU count and batch count - max_workers = min( - os.cpu_count(), # Number of CPU cores - len(batches), # Number of batches - 20, - ) - connection_id = str(uuid.uuid4()) - # Process batches in parallel - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_batch = { - executor.submit( - self.__process_batch, - data_config["client_id"], - batch, - connection_id, - ): batch - for batch in batches - } - - for future in as_completed(future_to_batch): - batch = future_to_batch[future] - try: - result = future.result() - if ( - isinstance(result, dict) - and result.get("message") == "200: Success" - ): - success_queue.extend(batch) - else: - fail_queue.extend(batch) - except Exception as e: - logging.exception(e) - logging.error(f"Batch upload failed: {str(e)}") - fail_queue.extend(batch) - - if not success_queue and fail_queue: - raise LabellerrError( - "All file uploads failed. Check individual file errors above." - ) - - return { - "connection_id": connection_id, - "success": success_queue, - "fail": fail_queue, - } - - except LabellerrError: - raise - except Exception as e: - logging.error(f"Failed to upload files: {str(e)}") - raise - - def __process_batch(self, client_id, files_list, connection_id=None): - """ - Processes a batch of files. - """ - # Prepare files for upload - files = {} - for file_path in files_list: - file_name = os.path.basename(file_path) - files[file_name] = file_path - - response = self.client.connect_local_files( - client_id, list(files.keys()), connection_id - ) - resumable_upload_links = response["response"]["resumable_upload_links"] - for file_name in resumable_upload_links.keys(): - gcs.upload_to_gcs_resumable( - resumable_upload_links[file_name], files[file_name] - ) - - return response - - def attach_dataset_to_project( - self, client_id, project_id, dataset_id=None, dataset_ids=None - ): - """ - Attaches one or more datasets to an existing project. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param dataset_id: The ID of a single dataset to attach (for backward compatibility) - :param dataset_ids: List of dataset IDs to attach (for batch operations) - :return: Dictionary containing attachment status - :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided - """ - # Handle both single and batch operations - if dataset_id is None and dataset_ids is None: - raise LabellerrError("Either dataset_id or dataset_ids must be provided") - - if dataset_id is not None and dataset_ids is not None: - raise LabellerrError( - "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." - ) - - # Convert single dataset_id to list for uniform processing - if dataset_id is not None: - dataset_ids = [dataset_id] - - # Validate parameters using Pydantic for each dataset - validated_dataset_ids = [] - for ds_id in dataset_ids: - params = schemas.AttachDatasetParams( - client_id=client_id, project_id=project_id, dataset_id=ds_id - ) - validated_dataset_ids.append(str(params.dataset_id)) - - # Use the first params validation for client_id and project_id - params = schemas.AttachDatasetParams( - client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/actions/jobs/add_datasets_to_project?project_id={params.project_id}&uuid={unique_id}&client_id={params.client_id}" - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload = json.dumps({"attached_datasets": validated_dataset_ids}) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - def detach_dataset_from_project( - self, client_id, project_id, dataset_id=None, dataset_ids=None - ): - """ - Detaches one or more datasets from an existing project. - - :param client_id: The ID of the client - :param project_id: The ID of the project - :param dataset_id: The ID of a single dataset to detach (for backward compatibility) - :param dataset_ids: List of dataset IDs to detach (for batch operations) - :return: Dictionary containing detachment status - :raises LabellerrError: If the operation fails or if neither dataset_id nor dataset_ids is provided - """ - # Handle both single and batch operations - if dataset_id is None and dataset_ids is None: - raise LabellerrError("Either dataset_id or dataset_ids must be provided") - - if dataset_id is not None and dataset_ids is not None: - raise LabellerrError( - "Cannot provide both dataset_id and dataset_ids. Use dataset_ids for batch operations." - ) - - # Convert single dataset_id to list for uniform processing - if dataset_id is not None: - dataset_ids = [dataset_id] - - # Validate parameters using Pydantic for each dataset - validated_dataset_ids = [] - for ds_id in dataset_ids: - params = schemas.DetachDatasetParams( - client_id=client_id, project_id=project_id, dataset_id=ds_id - ) - validated_dataset_ids.append(str(params.dataset_id)) - - # Use the first params validation for client_id and project_id - params = schemas.DetachDatasetParams( - client_id=client_id, project_id=project_id, dataset_id=dataset_ids[0] - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/actions/jobs/delete_datasets_from_project?project_id={params.project_id}&uuid={unique_id}" - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - payload = json.dumps({"attached_datasets": validated_dataset_ids}) - - return client_utils.request( - "POST", url, headers=headers, data=payload, request_id=unique_id - ) - - @validate_params(client_id=str, datatype=str, project_id=str, scope=str) - def get_all_datasets( - self, client_id: str, datatype: str, project_id: str, scope: str - ): - """ - Retrieves datasets by parameters. - - :param client_id: The ID of the client. - :param datatype: The type of data for the dataset. - :param project_id: The ID of the project. - :param scope: The permission scope for the dataset. - :return: The dataset list as JSON. - """ - # Validate parameters using Pydantic - params = schemas.GetAllDatasetParams( - client_id=client_id, - datatype=datatype, - project_id=project_id, - scope=scope, - ) - unique_id = str(uuid.uuid4()) - url = ( - f"{constants.BASE_URL}/datasets/list?client_id={params.client_id}&data_type={params.datatype}&permission_level={params.scope}" - f"&project_id={params.project_id}&uuid={unique_id}" - ) - headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - client_id=params.client_id, - extra_headers={"content-type": "application/json"}, - ) - - return client_utils.request("GET", url, headers=headers, request_id=unique_id) diff --git a/labellerr/core/datasets/video_dataset.py b/labellerr/core/datasets/video_dataset.py index 4f80728..f5d6f83 100644 --- a/labellerr/core/datasets/video_dataset.py +++ b/labellerr/core/datasets/video_dataset.py @@ -1,8 +1,9 @@ +import uuid + from .. import constants -from ..files import LabellerrFile from ..exceptions import LabellerrError +from ..files import LabellerrFile from .base import LabellerrDataset, LabellerrDatasetMeta -import uuid class VideoDataset(LabellerrDataset): diff --git a/labellerr/core/exceptions/__init__.py b/labellerr/core/exceptions/__init__.py index 02c451d..ddb55c6 100644 --- a/labellerr/core/exceptions/__init__.py +++ b/labellerr/core/exceptions/__init__.py @@ -17,3 +17,11 @@ class InvalidProjectError(Exception): """Custom exception for invalid project errors.""" pass + + +class InvalidDatasetIDError(Exception): + pass + + +class InvalidConnectionError(Exception): + pass diff --git a/labellerr/core/files/base.py b/labellerr/core/files/base.py index f7d2bd3..c49500e 100644 --- a/labellerr/core/files/base.py +++ b/labellerr/core/files/base.py @@ -1,8 +1,12 @@ -from ..client import LabellerrClient -from ..exceptions import LabellerrError -from .. import constants import uuid from abc import ABCMeta +from typing import TYPE_CHECKING + +from .. import constants +from ..exceptions import LabellerrError + +if TYPE_CHECKING: + from ..client import LabellerrClient class LabellerrFileMeta(ABCMeta): @@ -83,7 +87,7 @@ class LabellerrFile(metaclass=LabellerrFileMeta): def __init__( self, - client: LabellerrClient, + client: "LabellerrClient", file_id: str, project_id: str, dataset_id: str | None = None, diff --git a/labellerr/core/files/video_file.py b/labellerr/core/files/video_file.py index 775ef66..efd6eea 100644 --- a/labellerr/core/files/video_file.py +++ b/labellerr/core/files/video_file.py @@ -1,22 +1,28 @@ -from ..client import LabellerrClient -from ..exceptions import LabellerrError -from .. import constants -import uuid import os -import subprocess -import requests import shutil +import subprocess +import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from threading import Lock +from typing import TYPE_CHECKING + +import requests + from labellerr.core.files.base import LabellerrFile, LabellerrFileMeta +from .. import constants +from ..exceptions import LabellerrError + +if TYPE_CHECKING: + from ..client import LabellerrClient + class LabellerrVideoFile(LabellerrFile): """Specialized class for handling video files including frame operations""" def __init__( self, - client: LabellerrClient, + client: "LabellerrClient", file_id: str, project_id: str, dataset_id: str | None = None, @@ -305,8 +311,8 @@ def download_create_video_auto_cleanup( "failed_frames_info": download_result["failed_frames"], } - print("\n{'='*60}") - print("✓ Processing complete!") + print(f"\n{'='*60}") + print("Processing complete!") print(f"Video saved to: {video_output_path}") print("{'='*60}\n") diff --git a/labellerr/core/projects/__init__.py b/labellerr/core/projects/__init__.py index d365069..3e9900e 100644 --- a/labellerr/core/projects/__init__.py +++ b/labellerr/core/projects/__init__.py @@ -1,4 +1,4 @@ -from .projects import LabellerrProject +from .base import LabellerrProject from .image_project import ImageProject as LabellerrImageProject from .video_project import VideoProject as LabellerrVideoProject diff --git a/labellerr/core/projects/base.py b/labellerr/core/projects/base.py index 330642c..9c52fdd 100644 --- a/labellerr/core/projects/base.py +++ b/labellerr/core/projects/base.py @@ -1,15 +1,22 @@ """This module will contain all CRUD for projects. Example, create, list projects, get project, delete project, update project, etc.""" -from abc import ABCMeta -from ..client import LabellerrClient -from .. import constants, client_utils -from ..exceptions import InvalidProjectError -import uuid -from ..exceptions import LabellerrError -import logging -import utils -from .. import schemas +import concurrent import json +import logging +import os +import uuid +from abc import ABCMeta +from datetime import time +from typing import TYPE_CHECKING, List + +import requests + +from .. import client_utils, constants, gcs, schemas, utils +from ..exceptions import InvalidProjectError, LabellerrError +from ..utils import validate_params + +if TYPE_CHECKING: + from ..client import LabellerrClient class LabellerrProjectMeta(ABCMeta): @@ -22,7 +29,7 @@ def register(cls, data_type, project_class): cls._registry[data_type] = project_class @staticmethod - def get_project(client: LabellerrClient, project_id: str): + def get_project(client: "LabellerrClient", project_id: str): """Get project from Labellerr API""" # ------------------------------- [needs refactoring after we consolidate api_calls into one function ] --------------------------------- unique_id = str(uuid.uuid4()) @@ -70,7 +77,7 @@ def __call__(cls, client, project_id, **kwargs): class LabellerrProject(metaclass=LabellerrProjectMeta): """Base class for all Labellerr projects with factory behavior""" - def __init__(self, client: LabellerrClient, project_id: str, **kwargs): + def __init__(self, client: "LabellerrClient", project_id: str, **kwargs): self.client = client self.project_id = project_id self.project_data = kwargs["project_data"] @@ -173,7 +180,7 @@ def initiate_create_project(self, payload): logging.info("Rotation configuration validated . . .") logging.info("Creating dataset . . .") - dataset_response = self.create_dataset( + dataset_response = self.client.datasets.create_dataset( { "client_id": payload["client_id"], "dataset_name": payload["dataset_name"], @@ -219,7 +226,7 @@ def dataset_ready(): if payload.get("annotation_template_id"): annotation_template_id = payload["annotation_template_id"] else: - annotation_template_id = self.create_annotation_guideline( + annotation_template_id = self.client.create_annotation_guideline( payload["client_id"], payload["annotation_guide"], payload["project_name"], @@ -302,8 +309,8 @@ def create_project( ) headers = client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, + api_key=self.client.api_key, + api_secret=self.client.api_secret, client_id=params.client_id, extra_headers={ "Origin": constants.ALLOWED_ORIGINS, @@ -314,3 +321,572 @@ def create_project( return client_utils.request( "POST", url, headers=headers, data=payload, request_id=unique_id ) + + def update_rotation_count(self): + """ + Updates the rotation count for a project. + + :return: A dictionary indicating the success of the operation. + """ + try: + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/projects/rotations/add?project_id={self.project_id}&client_id={self.client.client_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=self.client.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps(self.rotation_config) + logging.info(f"Update Rotation Count Payload: {payload}") + + response = requests.request("POST", url, headers=headers, data=payload) + + logging.info("Rotation configuration updated successfully.") + client_utils.handle_response(response, unique_id) + + return {"msg": "project rotation configuration updated"} + except LabellerrError as e: + logging.error(f"Project rotation update config failed: {e}") + raise + + def get_all_project_per_client_id(self, client_id): + """ + Retrieves a list of projects associated with a client ID. + + :param client_id: The ID of the client. + :return: A dictionary containing the list of projects. + :raises LabellerrError: If the retrieval fails. + """ + try: + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/project_drafts/projects/detailed_list?client_id={client_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=client_id, + extra_headers={"content-type": "application/json"}, + ) + + response = requests.request("GET", url, headers=headers, data={}) + return client_utils.handle_response(response, unique_id) + except Exception as e: + logging.error(f"Failed to retrieve projects: {str(e)}") + raise + + def _upload_preannotation_sync( + self, project_id, client_id, annotation_format, annotation_file + ): + """ + Synchronous implementation of preannotation upload. + + :param project_id: The ID of the project. + :param client_id: The ID of the client. + :param annotation_format: The format of the preannotation data. + :param annotation_file: The file path of the preannotation data. + :return: The response from the API. + :raises LabellerrError: If the upload fails. + """ + try: + # validate all the parameters + required_params = { + "project_id": project_id, + "client_id": client_id, + "annotation_format": annotation_format, + "annotation_file": annotation_file, + } + client_utils.validate_required_params( + required_params, list(required_params.keys()) + ) + client_utils.validate_annotation_format(annotation_format, annotation_file) + + request_uuid = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/actions/upload_answers?project_id={project_id}" + f"&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" + ) + file_name = client_utils.validate_file_exists(annotation_file) + # get the direct upload url + gcs_path = f"{project_id}/{annotation_format}-{file_name}" + logging.info("Uploading your file to Labellerr. Please wait...") + direct_upload_url = self.client.get_direct_upload_url(gcs_path, client_id) + # Now let's wait for the file to be uploaded to the gcs + gcs.upload_to_gcs_direct(direct_upload_url, annotation_file) + payload = {} + # with open(annotation_file, 'rb') as f: + # files = [ + # ('file', (file_name, f, 'application/octet-stream')) + # ] + # response = requests.request("POST", url, headers={ + # 'client_id': client_id, + # 'api_key': self.api_key, + # 'api_secret': self.api_secret, + # 'origin': constants.ALLOWED_ORIGINS, + # 'source':'sdk', + # 'email_id': self.api_key + # }, data=payload, files=files) + url += "&gcs_path=" + gcs_path + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=client_id, + extra_headers={"email_id": self.client.api_key}, + ) + response = requests.request("POST", url, headers=headers, data=payload) + response_data = self.client._handle_upload_response(response, request_uuid) + + # read job_id from the response + job_id = response_data["response"]["job_id"] + self.client_id = client_id + self.job_id = job_id + self.project_id = project_id + + logging.info(f"Preannotation upload successful. Job ID: {job_id}") + + # Use max_retries=10 with 5-second intervals = 50 seconds max (fits within typical test timeouts) + future = self.preannotation_job_status_async( + max_retries=10, retry_interval=5 + ) + return future.result() + except Exception as e: + logging.error(f"Failed to upload preannotation: {str(e)}") + raise + + def upload_preannotation_by_project_id_async( + self, project_id, client_id, annotation_format, annotation_file + ): + """ + Asynchronously uploads preannotation data to a project. + + :param project_id: The ID of the project. + :param client_id: The ID of the client. + :param annotation_format: The format of the preannotation data. + :param annotation_file: The file path of the preannotation data. + :return: A Future object that will contain the response from the API. + :raises LabellerrError: If the upload fails. + """ + + def upload_and_monitor(): + try: + # validate all the parameters + required_params = [ + "project_id", + "client_id", + "annotation_format", + "annotation_file", + ] + for param in required_params: + if param not in locals(): + raise LabellerrError(f"Required parameter {param} is missing") + + if annotation_format not in constants.ANNOTATION_FORMAT: + raise LabellerrError( + f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" + ) + + request_uuid = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/actions/upload_answers?" + f"project_id={project_id}&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" + ) + + # validate if the file exist then extract file name from the path + if os.path.exists(annotation_file): + file_name = os.path.basename(annotation_file) + else: + raise LabellerrError("File not found") + + # Check if the file extension is .json when annotation_format is coco_json + if annotation_format == "coco_json": + file_extension = os.path.splitext(annotation_file)[1].lower() + if file_extension != ".json": + raise LabellerrError( + "For coco_json annotation format, the file must have a .json extension" + ) + # get the direct upload url + gcs_path = f"{project_id}/{annotation_format}-{file_name}" + logging.info("Uploading your file to Labellerr. Please wait...") + direct_upload_url = self.client.get_direct_upload_url( + gcs_path, client_id + ) + # Now let's wait for the file to be uploaded to the gcs + gcs.upload_to_gcs_direct(direct_upload_url, annotation_file) + payload = {} + # with open(annotation_file, 'rb') as f: + # files = [ + # ('file', (file_name, f, 'application/octet-stream')) + # ] + # response = requests.request("POST", url, headers={ + # 'client_id': client_id, + # 'api_key': self.api_key, + # 'api_secret': self.api_secret, + # 'origin': constants.ALLOWED_ORIGINS, + # 'source':'sdk', + # 'email_id': self.api_key + # }, data=payload, files=files) + url += "&gcs_path=" + gcs_path + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=client_id, + extra_headers={"email_id": self.client.api_key}, + ) + response = requests.request("POST", url, headers=headers, data=payload) + response_data = self.client._handle_upload_response( + response, request_uuid + ) + + # read job_id from the response + job_id = response_data["response"]["job_id"] + self.client_id = client_id + self.job_id = job_id + self.project_id = project_id + + logging.info(f"Pre annotation upload successful. Job ID: {job_id}") + + # Now monitor the status + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=self.client_id, + extra_headers={"Origin": constants.ALLOWED_ORIGINS}, + ) + status_url = f"{constants.BASE_URL}/actions/upload_answers_status?project_id={self.project_id}&job_id={self.job_id}&client_id={self.client_id}" + while True: + try: + response = requests.request( + "GET", status_url, headers=headers, data={} + ) + status_data = response.json() + + logging.debug(f"Status data: {status_data}") + + # Check if job is completed + if status_data.get("response", {}).get("status") == "completed": + return status_data + + logging.info("Syncing status after 5 seconds . . .") + time.sleep(5) + + except Exception as e: + logging.error( + f"Failed to get preannotation job status: {str(e)}" + ) + raise + + except Exception as e: + logging.exception(f"Failed to upload preannotation: {str(e)}") + raise + + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(upload_and_monitor) + + def preannotation_job_status_async(self, max_retries=60, retry_interval=5): + """ + Get the status of a preannotation job asynchronously with timeout protection. + + Args: + max_retries: Maximum number of retries before timing out (default: 60 retries = 5 minutes) + retry_interval: Seconds to wait between retries (default: 5 seconds) + + Returns: + concurrent.futures.Future: A future that will contain the final job status + + Raises: + LabellerrError: If max retries exceeded or job status check fails + """ + + def check_status(): + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=self.client_id, + extra_headers={"Origin": constants.ALLOWED_ORIGINS}, + ) + url = f"{constants.BASE_URL}/actions/upload_answers_status?project_id={self.project_id}&job_id={self.job_id}&client_id={self.client_id}" + payload = {} + retry_count = 0 + + while retry_count < max_retries: + try: + response = requests.request( + "GET", url, headers=headers, data=payload + ) + response_data = response.json() + + # Check if job is completed + if response_data.get("response", {}).get("status") == "completed": + logging.info( + f"Pre-annotation job completed after {retry_count} retries" + ) + return response_data + + retry_count += 1 + if retry_count < max_retries: + logging.info( + f"Retry {retry_count}/{max_retries}: Job not complete, retrying after {retry_interval} seconds..." + ) + time.sleep(retry_interval) + else: + # Max retries exceeded + total_wait_time = max_retries * retry_interval + raise LabellerrError( + f"Pre-annotation job did not complete after {max_retries} retries " + f"({total_wait_time} seconds). Job ID: {self.job_id}. " + f"Last status: {response_data.get('response', {}).get('status', 'unknown')}" + ) + + except LabellerrError: + # Re-raise LabellerrError without wrapping + raise + except Exception as e: + logging.error(f"Failed to get preannotation job status: {str(e)}") + raise LabellerrError( + f"Failed to get preannotation job status: {str(e)}" + ) + return None + + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(check_status) + + def upload_preannotation_by_project_id( + self, project_id, client_id, annotation_format, annotation_file + ): + """ + Uploads preannotation data to a project. + + :param project_id: The ID of the project. + :param client_id: The ID of the client. + :param annotation_format: The format of the preannotation data. + :param annotation_file: The file path of the preannotation data. + :return: The response from the API. + :raises LabellerrError: If the upload fails. + """ + try: + # validate all the parameters + required_params = [ + "project_id", + "client_id", + "annotation_format", + "annotation_file", + ] + for param in required_params: + if param not in locals(): + raise LabellerrError(f"Required parameter {param} is missing") + + if annotation_format not in constants.ANNOTATION_FORMAT: + raise LabellerrError( + f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" + ) + + request_uuid = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/actions/upload_answers?project_id={project_id}" + f"&answer_format={annotation_format}&client_id={client_id}&uuid={request_uuid}" + ) + + # validate if the file exist then extract file name from the path + if os.path.exists(annotation_file): + file_name = os.path.basename(annotation_file) + else: + raise LabellerrError("File not found") + + payload = {} + with open(annotation_file, "rb") as f: + files = [("file", (file_name, f, "application/octet-stream"))] + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=client_id, + extra_headers={"email_id": self.client.api_key}, + ) + response = requests.request( + "POST", url, headers=headers, data=payload, files=files + ) + response_data = self.client._handle_upload_response(response, request_uuid) + logging.debug(f"response_data: {response_data}") + + # read job_id from the response + job_id = response_data["response"]["job_id"] + self.client_id = client_id + self.job_id = job_id + self.project_id = project_id + + logging.info(f"Preannotation upload successful. Job ID: {job_id}") + + # Use max_retries=10 with 5-second intervals = 50 seconds max (fits within typical test timeouts) + future = self.preannotation_job_status_async( + max_retries=10, retry_interval=5 + ) + return future.result() + except Exception as e: + logging.error(f"Failed to upload preannotation: {str(e)}") + raise + + def create_local_export(self, project_id, client_id, export_config): + """ + Creates a local export with the given configuration. + + :param project_id: The ID of the project. + :param client_id: The ID of the client. + :param export_config: Export configuration dictionary. + :return: The response from the API. + :raises LabellerrError: If the export creation fails. + """ + # Validate parameters using Pydantic + schemas.CreateLocalExportParams( + project_id=project_id, + client_id=client_id, + export_config=export_config, + ) + # Validate export config using client_utils + client_utils.validate_export_config(export_config) + + unique_id = client_utils.generate_request_id() + export_config.update({"export_destination": "local", "question_ids": ["all"]}) + + payload = json.dumps(export_config) + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + extra_headers={ + "Origin": constants.ALLOWED_ORIGINS, + "Content-Type": "application/json", + }, + ) + + return client_utils.request( + "POST", + f"{constants.BASE_URL}/sdk/export/files?project_id={project_id}&client_id={client_id}", + headers=headers, + data=payload, + request_id=unique_id, + ) + + @validate_params(project_id=str, report_ids=list, client_id=str) + def check_export_status( + self, project_id: str, report_ids: List[str], client_id: str + ): + request_uuid = client_utils.generate_request_id() + try: + if not project_id: + raise LabellerrError("project_id cannot be null") + if not report_ids: + raise LabellerrError("report_ids cannot be empty") + + # Construct URL + url = f"{constants.BASE_URL}/exports/status?project_id={project_id}&uuid={request_uuid}&client_id={client_id}" + + # Headers + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=client_id, + extra_headers={"Content-Type": "application/json"}, + ) + + payload = json.dumps({"report_ids": report_ids}) + + response = requests.post(url, headers=headers, data=payload) + result = client_utils.handle_response(response, request_uuid) + + # Now process each report_id + for status_item in result.get("status", []): + if ( + status_item.get("is_completed") + and status_item.get("export_status") == "Created" + ): + # Download URL if job completed + download_url = ( # noqa E999 todo check use of that + self.client.fetch_download_url( + project_id=project_id, + uuid=request_uuid, + export_id=status_item["report_id"], + client_id=client_id, + ) + ) + + return json.dumps(result, indent=2) + + except requests.exceptions.RequestException as e: + logging.error(f"Failed to check export status: {str(e)}") + raise + except Exception as e: + logging.error(f"Unexpected error checking export status: {str(e)}") + raise + + def list_file( + self, client_id, project_id, search_queries, size=10, next_search_after=None + ): + # Validate parameters using Pydantic + params = schemas.ListFileParams( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=size, + next_search_after=next_search_after, + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/search/project_files?project_id={params.project_id}&client_id={params.client_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps( + { + "search_queries": params.search_queries, + "size": params.size, + "next_search_after": params.next_search_after, + } + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def bulk_assign_files(self, client_id, project_id, file_ids, new_status): + # Validate parameters using Pydantic + params = schemas.BulkAssignFilesParams( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/files/bulk_assign?project_id={params.project_id}&uuid={unique_id}&client_id={params.client_id}" + + headers = client_utils.build_headers( + api_key=self.client.api_key, + api_secret=self.client.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps( + { + "file_ids": params.file_ids, + "new_status": params.new_status, + } + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def validate_rotation_config(self, rotation_config): + """ + Validates a rotation configuration. + + :param rotation_config: A dictionary containing the configuration for the rotations. + :raises LabellerrError: If the configuration is invalid. + """ + client_utils.validate_rotation_config(rotation_config) diff --git a/labellerr/core/projects/image_project.py b/labellerr/core/projects/image_project.py index eefeddd..ead5422 100644 --- a/labellerr/core/projects/image_project.py +++ b/labellerr/core/projects/image_project.py @@ -1,4 +1,4 @@ -from .projects import LabellerrProject, LabellerrProjectMeta +from .base import LabellerrProject, LabellerrProjectMeta class ImageProject(LabellerrProject): diff --git a/labellerr/core/projects/video_project.py b/labellerr/core/projects/video_project.py index 82610a7..7bdac93 100644 --- a/labellerr/core/projects/video_project.py +++ b/labellerr/core/projects/video_project.py @@ -1,4 +1,4 @@ -from .projects import LabellerrProject +from .base import LabellerrProject class VideoProject(LabellerrProject): diff --git a/labellerr/core/schemas.py b/labellerr/core/schemas.py index a09d073..9f72055 100644 --- a/labellerr/core/schemas.py +++ b/labellerr/core/schemas.py @@ -339,3 +339,15 @@ class BulkAssignFilesParams(BaseModel): project_id: str = Field(min_length=1) file_ids: List[str] = Field(min_length=1) new_status: str = Field(min_length=1) + + +class SyncDataSetParams(BaseModel): + """Parameters for syncing datasets from cloud storage.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + dataset_id: str = Field(min_length=1) + path: str = Field(min_length=1) + data_type: str = Field(min_length=1) + email_id: str = Field(min_length=1) + connection_id: str = Field(min_length=1) diff --git a/labellerr/core/users/base.py b/labellerr/core/users/base.py new file mode 100644 index 0000000..f101b84 --- /dev/null +++ b/labellerr/core/users/base.py @@ -0,0 +1,384 @@ +import json +import uuid + +from labellerr import schemas +from labellerr.core import client_utils, constants +from labellerr.core.base.singleton import Singleton + + +class LabellerrUsers(Singleton): + + def create_user( + self, + client_id, + first_name, + last_name, + email_id, + projects, + roles, + work_phone="", + job_title="", + language="en", + timezone="GMT", + ): + """ + Creates a new user in the system. + + :param client_id: The ID of the client + :param first_name: User's first name + :param last_name: User's last name + :param email_id: User's email address + :param projects: List of project IDs to assign the user to + :param roles: List of role objects with project_id and role_id + :param work_phone: User's work phone number (optional) + :param job_title: User's job title (optional) + :param language: User's preferred language (default: "en") + :param timezone: User's timezone (default: "GMT") + :return: Dictionary containing user creation response + :raises LabellerrError: If the creation fails + """ + # Validate parameters using Pydantic + params = schemas.CreateUserParams( + client_id=client_id, + first_name=first_name, + last_name=last_name, + email_id=email_id, + projects=projects, + roles=roles, + work_phone=work_phone, + job_title=job_title, + language=language, + timezone=timezone, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/register?client_id={params.client_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={ + "content-type": "application/json", + "accept": "application/json, text/plain, */*", + }, + ) + + payload = json.dumps( + { + "first_name": params.first_name, + "last_name": params.last_name, + "work_phone": params.work_phone, + "job_title": params.job_title, + "language": params.language, + "timezone": params.timezone, + "email_id": params.email_id, + "projects": params.projects, + "client_id": params.client_id, + "roles": params.roles, + } + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def update_user_role( + self, + client_id, + project_id, + email_id, + roles, + first_name=None, + last_name=None, + work_phone="", + job_title="", + language="en", + timezone="GMT", + profile_image="", + ): + """ + Updates a user's role and profile information. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param email_id: User's email address + :param roles: List of role objects with project_id and role_id + :param first_name: User's first name (optional) + :param last_name: User's last name (optional) + :param work_phone: User's work phone number (optional) + :param job_title: User's job title (optional) + :param language: User's preferred language (default: "en") + :param timezone: User's timezone (default: "GMT") + :param profile_image: User's profile image (optional) + :return: Dictionary containing update response + :raises LabellerrError: If the update fails + """ + # Validate parameters using Pydantic + params = schemas.UpdateUserRoleParams( + client_id=client_id, + project_id=project_id, + email_id=email_id, + roles=roles, + first_name=first_name, + last_name=last_name, + work_phone=work_phone, + job_title=job_title, + language=language, + timezone=timezone, + profile_image=profile_image, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/update?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={ + "content-type": "application/json", + "accept": "application/json, text/plain, */*", + }, + ) + + # Build the payload with all provided information + # Extract project_ids from roles for API requirement + project_ids = [ + role.get("project_id") for role in params.roles if "project_id" in role + ] + + payload_data = { + "profile_image": params.profile_image, + "work_phone": params.work_phone, + "job_title": params.job_title, + "language": params.language, + "timezone": params.timezone, + "email_id": params.email_id, + "client_id": params.client_id, + "roles": params.roles, + "projects": project_ids, # API requires projects list extracted from roles (same format as create_user) + } + + # Add optional fields if provided + if params.first_name is not None: + payload_data["first_name"] = params.first_name + if params.last_name is not None: + payload_data["last_name"] = params.last_name + + payload = json.dumps(payload_data) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def delete_user( + self, + client_id, + project_id, + email_id, + user_id, + first_name=None, + last_name=None, + is_active=1, + role="Annotator", + user_created_at=None, + max_activity_created_at=None, + image_url="", + name=None, + activity="No Activity", + creation_date=None, + status="Activated", + ): + """ + Deletes a user from the system. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param email_id: User's email address + :param user_id: User's unique identifier + :param first_name: User's first name (optional) + :param last_name: User's last name (optional) + :param is_active: User's active status (default: 1) + :param role: User's role (default: "Annotator") + :param user_created_at: User creation timestamp (optional) + :param max_activity_created_at: Max activity timestamp (optional) + :param image_url: User's profile image URL (optional) + :param name: User's display name (optional) + :param activity: User's activity status (default: "No Activity") + :param creation_date: User creation date (optional) + :param status: User's status (default: "Activated") + :return: Dictionary containing deletion response + :raises LabellerrError: If the deletion fails + """ + # Validate parameters using Pydantic + params = schemas.DeleteUserParams( + client_id=client_id, + project_id=project_id, + email_id=email_id, + user_id=user_id, + first_name=first_name, + last_name=last_name, + is_active=is_active, + role=role, + user_created_at=user_created_at, + max_activity_created_at=max_activity_created_at, + image_url=image_url, + name=name, + activity=activity, + creation_date=creation_date, + status=status, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/delete?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={ + "content-type": "application/json", + "accept": "application/json, text/plain, */*", + }, + ) + + # Build the payload with all provided information + payload_data = { + "email_id": params.email_id, + "is_active": params.is_active, + "role": params.role, + "user_id": params.user_id, + "imageUrl": params.image_url, + "email": params.email_id, + "activity": params.activity, + "status": params.status, + } + + # Add optional fields if provided + if params.first_name is not None: + payload_data["first_name"] = params.first_name + if params.last_name is not None: + payload_data["last_name"] = params.last_name + if params.user_created_at is not None: + payload_data["user_created_at"] = params.user_created_at + if params.max_activity_created_at is not None: + payload_data["max_activity_created_at"] = params.max_activity_created_at + if params.name is not None: + payload_data["name"] = params.name + if params.creation_date is not None: + payload_data["creationDate"] = params.creation_date + + payload = json.dumps(payload_data) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def add_user_to_project(self, client_id, project_id, email_id, role_id=None): + """ + Adds a user to a project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param email_id: User's email address + :param role_id: Optional role ID to assign to the user + :return: Dictionary containing addition response + :raises LabellerrError: If the addition fails + """ + # Validate parameters using Pydantic + params = schemas.AddUserToProjectParams( + client_id=client_id, + project_id=project_id, + email_id=email_id, + role_id=role_id, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/add_user_to_project?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload_data = {"email_id": params.email_id, "uuid": unique_id} + + if params.role_id is not None: + payload_data["role_id"] = params.role_id + + payload = json.dumps(payload_data) + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def remove_user_from_project(self, client_id, project_id, email_id): + """ + Removes a user from a project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param email_id: User's email address + :return: Dictionary containing removal response + :raises LabellerrError: If the removal fails + """ + # Validate parameters using Pydantic + params = schemas.RemoveUserFromProjectParams( + client_id=client_id, project_id=project_id, email_id=email_id + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/remove_user_from_project?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload_data = {"email_id": params.email_id, "uuid": unique_id} + + payload = json.dumps(payload_data) + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + # TODO: this is not working from UI + def change_user_role(self, client_id, project_id, email_id, new_role_id): + """ + Changes a user's role in a project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param email_id: User's email address + :param new_role_id: The new role ID to assign to the user + :return: Dictionary containing role change response + :raises LabellerrError: If the role change fails + """ + # Validate parameters using Pydantic + params = schemas.ChangeUserRoleParams( + client_id=client_id, + project_id=project_id, + email_id=email_id, + new_role_id=new_role_id, + ) + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/users/change_user_role?client_id={params.client_id}&project_id={params.project_id}&uuid={unique_id}" + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload_data = { + "email_id": params.email_id, + "new_role_id": params.new_role_id, + "uuid": unique_id, + } + + payload = json.dumps(payload_data) + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) diff --git a/labellerr/schemas.py b/labellerr/schemas.py index a09d073..f5c1b14 100644 --- a/labellerr/schemas.py +++ b/labellerr/schemas.py @@ -328,7 +328,7 @@ class ListFileParams(BaseModel): client_id: str = Field(min_length=1) project_id: str = Field(min_length=1) search_queries: Dict[str, Any] - size: int = 10 + size: int = Field(default=10, gt=0) next_search_after: Optional[Any] = None @@ -339,3 +339,15 @@ class BulkAssignFilesParams(BaseModel): project_id: str = Field(min_length=1) file_ids: List[str] = Field(min_length=1) new_status: str = Field(min_length=1) + + +class SyncDataSetParams(BaseModel): + """Parameters for syncing datasets.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + dataset_id: str = Field(min_length=1) + path: str = Field(min_length=1) + data_type: Literal["image", "video", "audio", "document", "text"] + email_id: str = Field(min_length=1) + connection_id: str = Field(min_length=1) diff --git a/labellerr/services/video_sampling/ffmpeg.py b/labellerr/services/video_sampling/ffmpeg.py index bbac620..43f1c59 100644 --- a/labellerr/services/video_sampling/ffmpeg.py +++ b/labellerr/services/video_sampling/ffmpeg.py @@ -1,8 +1,10 @@ -import subprocess -import os import json -from pydantic import BaseModel, Field +import os +import subprocess from typing import List + +from pydantic import BaseModel, Field + from labellerr.core.base.singleton import Singleton diff --git a/labellerr/services/video_sampling/gemini.py b/labellerr/services/video_sampling/gemini.py index ad1e02a..614c42d 100644 --- a/labellerr/services/video_sampling/gemini.py +++ b/labellerr/services/video_sampling/gemini.py @@ -1,10 +1,12 @@ +import json import os +from typing import List, Optional + import cv2 +from google.cloud import videointelligence from PIL import Image from pydantic import BaseModel, Field -from typing import List, Optional -import json -from google.cloud import videointelligence + from labellerr.core.base.singleton import Singleton diff --git a/labellerr/services/video_sampling/pyscene_detect.py b/labellerr/services/video_sampling/pyscene_detect.py index 22836f4..bc93070 100644 --- a/labellerr/services/video_sampling/pyscene_detect.py +++ b/labellerr/services/video_sampling/pyscene_detect.py @@ -1,10 +1,12 @@ +import json import os -from scenedetect import detect, AdaptiveDetector -from PIL import Image +from typing import List + import cv2 +from PIL import Image from pydantic import BaseModel, Field -from typing import List -import json +from scenedetect import AdaptiveDetector, detect + from labellerr.core.base.singleton import Singleton diff --git a/labellerr/services/video_sampling/ssim.py b/labellerr/services/video_sampling/ssim.py index 1b4965c..285a4c9 100644 --- a/labellerr/services/video_sampling/ssim.py +++ b/labellerr/services/video_sampling/ssim.py @@ -1,11 +1,13 @@ +import json import os +from typing import List + import cv2 import numpy as np from PIL import Image from pydantic import BaseModel, Field -from typing import List -import json from skimage.metrics import structural_similarity as ssim + from labellerr.core.base.singleton import Singleton diff --git a/requirements.txt b/requirements.txt index 30d06f9..7a790a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ python-dotenv requests pytest pydantic>=2.0.0 -aiofiles \ No newline at end of file +aiofiles diff --git a/tests/integration/.gitignore b/tests/integration/.gitignore index b3330b5..4134b79 100644 --- a/tests/integration/.gitignore +++ b/tests/integration/.gitignore @@ -1,3 +1,3 @@ -__pychache__ +__pycache__ .env .venv diff --git a/tests/integration/Create_Project.py b/tests/integration/Create_Project.py index af94156..c19a73d 100644 --- a/tests/integration/Create_Project.py +++ b/tests/integration/Create_Project.py @@ -5,14 +5,14 @@ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "SDKPython")) ) + # Add the root directory to Python path root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.append(root_dir) import uuid -from SDKPython.labellerr.client import LabellerrClient -from SDKPython.labellerr.exceptions import LabellerrError +from labellerr import LabellerrClient, LabellerrError def create_project_all_option_type( diff --git a/tests/integration/Export_project.py b/tests/integration/Export_project.py index 26964db..1a1a6ae 100644 --- a/tests/integration/Export_project.py +++ b/tests/integration/Export_project.py @@ -1,6 +1,8 @@ import os import sys +from labellerr import LabellerrError + sys.path.append( os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "SDKPython")) ) @@ -9,8 +11,7 @@ root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.append(root_dir) -from SDKPython.labellerr.client import LabellerrClient -from SDKPython.labellerr.exceptions import LabellerrError +from labellerr.client import LabellerrClient def export_project(api_key, api_secret, client_id, project_id): diff --git a/tests/integration/Pre_annotation_uploading.py b/tests/integration/Pre_annotation_uploading.py index c077434..6aa20e8 100644 --- a/tests/integration/Pre_annotation_uploading.py +++ b/tests/integration/Pre_annotation_uploading.py @@ -1,6 +1,8 @@ import os import sys +from labellerr import LabellerrError + sys.path.append( os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "SDKPython")) ) @@ -9,8 +11,7 @@ root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.append(root_dir) -from SDKPython.labellerr.client import LabellerrClient -from SDKPython.labellerr.exceptions import LabellerrError +from labellerr.client import LabellerrClient def pre_annotation_uploading( diff --git a/tests/integration/bulk_assign_operations.py b/tests/integration/bulk_assign_operations.py new file mode 100644 index 0000000..742ab0c --- /dev/null +++ b/tests/integration/bulk_assign_operations.py @@ -0,0 +1,403 @@ +""" +Real integration tests for bulk assign and list file operations. + +This module contains integration tests that make actual API calls to test +bulk_assign_files and list_file operations in real-world scenarios. + +Usage: + python Bulk_Assign_Operations.py +""" + +import os +import sys + +# Add the root directory to Python path +root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.append(root_dir) + +import time + +from labellerr.client import LabellerrClient +from labellerr.exceptions import LabellerrError + + +def test_list_files_by_status(api_key, api_secret, client_id, project_id): + """ + Test listing files by status. + + Business scenario: Project manager wants to see all files in a specific status + to track progress and plan resource allocation. + """ + print("\n" + "=" * 60) + print("TEST: List Files by Status") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + # List all files without specific status filter + print("\n1. Listing files (first page)...") + result = client.list_file( + client_id=client_id, project_id=project_id, search_queries={}, size=10 + ) + + print("Successfully retrieved files") + if "files" in result: + print("Found {len(result.get('files', []))} files") + else: + print("Response: {result}") + + return result + + except LabellerrError as e: + print(f"Error: {str(e)}") + return None + + +def test_list_files_with_pagination(api_key, api_secret, client_id, project_id): + """ + Test listing files with pagination. + + Business scenario: Large projects need to paginate through files + for performance and to process files in batches. + """ + print("\n" + "=" * 60) + print("TEST: List Files with Pagination") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + # Get first page + print("\n1. Fetching first page (5 items)...") + result_page1 = client.list_file( + client_id=client_id, project_id=project_id, search_queries={}, size=5 + ) + + print("Page 1 retrieved successfully") + if "files" in result_page1: + print("Page 1 contains {len(result_page1.get('files', []))} files") + + # Check if there's a next page cursor + next_cursor = result_page1.get("next_search_after") + if next_cursor: + print("\n2. Next page cursor found, fetching second page...") + result_page2 = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries={}, + size=5, + next_search_after=next_cursor, + ) + print("Page 2 retrieved successfully") + if "files" in result_page2: + print("Page 2 contains {len(result_page2.get('files', []))} files") + else: + print(" ℹ No more pages available") + + return result_page1 + + except LabellerrError as e: + print(f"Error: {str(e)}") + return None + + +def test_bulk_assign_files( + api_key, api_secret, client_id, project_id, file_ids, new_status +): + """ + Test bulk assigning files to a new status. + + Business scenario: Project manager needs to move multiple files to a new stage + in the annotation pipeline efficiently. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID + file_ids: List of file IDs to assign + new_status: New status to assign to files + """ + print("\n" + "=" * 60) + print("TEST: Bulk Assign Files") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + print(f"\n1. Bulk assigning {len(file_ids)} files to status: {new_status}") + print("File IDs: {file_ids[:3]}{'...' if len(file_ids) > 3 else ''}") + + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + + print("Bulk assign successful") + print("Response: {result}") + + return result + + except LabellerrError as e: + print(f"Error: {str(e)}") + return None + + +def test_list_then_bulk_assign_workflow( + api_key, api_secret, client_id, project_id, target_status, new_status +): + """ + Test complete workflow: List files with specific status, then bulk assign them to new status. + + Business scenario: Project manager identifies files in one stage and moves them + to the next stage in the annotation pipeline. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID + target_status: Status to search for + new_status: New status to assign files to + """ + print("\n" + "=" * 60) + print("TEST: List Then Bulk Assign Workflow") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + # Step 1: List files with target status + print(f"\n1. Listing files with status: {target_status}") + list_result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries={"status": target_status}, + size=5, # Limit to 5 for testing + ) + + print("Files listed successfully") + + # Extract file IDs from result + files = list_result.get("files", []) + if not files: + print("ℹ No files found with status: {target_status}") + return None + + file_ids = [f["id"] for f in files if "id" in f] + if not file_ids: + print("ℹ No file IDs found in response") + return None + + print("Found {len(file_ids)} files to process") + + # Step 2: Bulk assign to new status + print(f"\n2. Bulk assigning {len(file_ids)} files to status: {new_status}") + assign_result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + + print("Bulk assign successful") + print("Workflow completed successfully!") + + # Step 3: Verify the change (optional) + print(f"\n3. Verifying files now have status: {new_status}") + time.sleep(1) # Brief pause to allow status update + verify_result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries={"status": new_status}, + size=len(file_ids) + 5, + ) + + print("Verification query successful") + + return { + "list_result": list_result, + "assign_result": assign_result, + "verify_result": verify_result, + } + + except LabellerrError as e: + print(f" Error: {str(e)}") + return None + + +def test_bulk_assign_single_file( + api_key, api_secret, client_id, project_id, file_id, new_status +): + """ + Test bulk assigning a single file. + + Business scenario: Sometimes need to change status of just one file using bulk API. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID + file_id: Single file ID to assign + new_status: New status to assign + """ + print("\n" + "=" * 60) + print("TEST: Bulk Assign Single File") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + print(f"\n1. Bulk assigning single file: {file_id}") + print("New status: {new_status}") + + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=[file_id], + new_status=new_status, + ) + + print("Single file bulk assign successful") + print("Response: {result}") + + return result + + except LabellerrError as e: + print(f"Error: {str(e)}") + return None + + +def test_search_with_filters(api_key, api_secret, client_id, project_id): + """ + Test searching files with complex filter criteria. + + Business scenario: Quality manager needs to find files matching specific criteria + for audit or review purposes. + """ + print("\n" + "=" * 60) + print("TEST: Search Files with Filters") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + # Test 1: Simple status filter + print("\n1. Searching with simple filters...") + result1 = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries={"status": "pending"}, + size=10, + ) + print("Simple filter search successful") + + # Test 2: Multiple filters (if supported) + print("\n2. Searching with multiple filters...") + result2 = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries={ + "status": "completed", + # Add more filters based on your API's capabilities + }, + size=10, + ) + print("Multiple filter search successful") + + return {"simple_filter": result1, "multiple_filters": result2} + + except LabellerrError as e: + print(f" Error: {str(e)}") + return None + + +def run_all_tests(api_key, api_secret, client_id, project_id): + """ + Run all integration tests for bulk assign operations. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID containing files to test with + """ + print("\n" + "=" * 80) + print(" BULK ASSIGN AND LIST FILE OPERATIONS - INTEGRATION TESTS") + print("=" * 80) + print(f"\nClient ID: {client_id}") + print(f"Project ID: {project_id}") + print("\n" + "=" * 80) + + # Test 1: List files + print("\n\n Running Test Suite: LIST FILES") + test_list_files_by_status(api_key, api_secret, client_id, project_id) + + # Test 2: Pagination + print("\n\n Running Test Suite: PAGINATION") + test_list_files_with_pagination(api_key, api_secret, client_id, project_id) + + # Test 3: Search with filters + print("\n\n Running Test Suite: SEARCH FILTERS") + test_search_with_filters(api_key, api_secret, client_id, project_id) + + +if __name__ == "__main__": + # Import credentials + try: + import cred + + API_KEY = cred.API_KEY + API_SECRET = cred.API_SECRET + CLIENT_ID = cred.CLIENT_ID + PROJECT_ID = cred.PROJECT_ID + except (ImportError, AttributeError): + # Fall back to environment variables + API_KEY = os.environ.get("LABELLERR_API_KEY", "") + API_SECRET = os.environ.get("LABELLERR_API_SECRET", "") + CLIENT_ID = os.environ.get("LABELLERR_CLIENT_ID", "") + PROJECT_ID = os.environ.get("LABELLERR_PROJECT_ID", "") + + # Check if credentials are available + if not all([API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID]): + print("\n" + "=" * 80) + print(" ERROR: Missing Credentials") + print("=" * 80) + print("\nPlease provide credentials either by:") + print("1. Setting them in tests/integration/cred.py:") + print(" API_KEY = 'your_api_key'") + print(" API_SECRET = 'your_api_secret'") + print(" CLIENT_ID = 'your_client_id'") + print(" PROJECT_ID = 'your_project_id'") + print("\n2. Or setting environment variables:") + print(" export LABELLERR_API_KEY='your_api_key'") + print(" export LABELLERR_API_SECRET='your_api_secret'") + print(" export LABELLERR_CLIENT_ID='your_client_id'") + print(" export LABELLERR_PROJECT_ID='your_project_id'") + print("\n" + "=" * 80) + sys.exit(1) + + # Run all tests + run_all_tests(API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID) + + # Example of running specific tests with file IDs + # Uncomment and modify these lines to test with actual file IDs + """ + # Example: Test bulk assign with specific file IDs + file_ids = ["file_id_1", "file_id_2", "file_id_3"] + test_bulk_assign_files(API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID, + file_ids, "annotation") + + # Example: Test complete workflow + test_list_then_bulk_assign_workflow(API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID, + target_status="pending", + new_status="annotation") + + # Example: Test single file + test_bulk_assign_single_file(API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID, + "single_file_id", "review") + """ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..722e8f8 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,133 @@ +""" +Pytest configuration and fixtures for integration tests. +""" + +import os +import sys + +import pytest +from dotenv import load_dotenv + +# Add the root directory to Python path +root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.append(root_dir) + +# Load .env file from the root directory +env_path = os.path.join(root_dir, ".env") +load_dotenv(env_path) + + +def get_credential(env_var, required=False): + """ + Get credential from environment variable (loaded from .env file). + + Args: + env_var: Environment variable name + required: If True, skip test if credential is not found + + Returns: + str: The credential value or None + """ + value = os.environ.get(env_var) + + # Check if required + if required and not value: + pytest.skip(f"Missing required credential: {env_var}") + + return value + + +@pytest.fixture(scope="session") +def api_key(): + """API key for authentication.""" + return get_credential("API_KEY", required=True) + + +@pytest.fixture(scope="session") +def api_secret(): + """API secret for authentication.""" + return get_credential("API_SECRET", required=True) + + +@pytest.fixture(scope="session") +def client_id(): + """Client ID.""" + return get_credential("CLIENT_ID", required=True) + + +@pytest.fixture(scope="session") +def project_id(): + """Project ID.""" + return get_credential("PROJECT_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def dataset_id(): + """Dataset ID for sync operations.""" + return get_credential("DATASET_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def path(): + """Path to the data.""" + return get_credential("PATH", required=False) or "/data" + + +@pytest.fixture(scope="session") +def data_type(): + """Type of data (image, video, audio, document, text).""" + return get_credential("DATA_TYPE", required=False) or "image" + + +@pytest.fixture(scope="session") +def email_id(): + """Email ID of the user.""" + return ( + get_credential("EMAIL_ID", required=False) + or get_credential("CLIENT_EMAIL", required=False) + or "" + ) + + +@pytest.fixture(scope="session") +def connection_id(): + """Connection ID.""" + return get_credential("CONNECTION_ID", required=False) or "" + + +# AWS-specific fixtures +@pytest.fixture(scope="session") +def aws_dataset_id(): + """Dataset ID for AWS sync operations.""" + return get_credential("AWS_DATASET_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def aws_connection_id(): + """Connection ID for AWS.""" + return get_credential("AWS_CONNECTION_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def aws_path(): + """Path to the AWS data (e.g., s3://bucket/path).""" + return get_credential("AWS_PATH", required=False) or "" + + +# GCS-specific fixtures +@pytest.fixture(scope="session") +def gcs_dataset_id(): + """Dataset ID for GCS sync operations.""" + return get_credential("GCS_DATASET_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def gcs_connection_id(): + """Connection ID for GCS.""" + return get_credential("GCS_CONNECTION_ID", required=False) or "" + + +@pytest.fixture(scope="session") +def gcs_path(): + """Path to the GCS data (e.g., gs://bucket/path).""" + return get_credential("GCS_PATH", required=False) or "" diff --git a/tests/integration/cred.py b/tests/integration/cred.py index 1735744..e69de29 100644 --- a/tests/integration/cred.py +++ b/tests/integration/cred.py @@ -1,6 +0,0 @@ -API_KEY = "" -API_SECRET = "" - -CLIENT_ID = "" -PROJECT_ID = "" -EMAIL_ID = "" diff --git a/tests/integration/main.py b/tests/integration/main.py index 3f0b4f3..26c3a01 100644 --- a/tests/integration/main.py +++ b/tests/integration/main.py @@ -1,4 +1,5 @@ import cred +from bulk_assign_operations import run_all_tests as test_bulk_assign_operations from Create_Project import ( create_project_all_option_type, create_project_boundingbox_dropdown_input, @@ -69,6 +70,12 @@ def test_pre_annotation_uploading(project_id, annotation_format, annotation_file print("\n Pre-annotation uploading completed.") +def test_bulk_assign_and_list_operations(project_id): + print("\n TESTING BULK ASSIGN AND LIST FILE OPERATIONS") + test_bulk_assign_operations(api_key, api_secret, client_id, project_id) + print("\n Bulk assign and list operations testing completed.") + + if __name__ == "__main__": test_dataset_path = ( diff --git a/tests/integration/sync_datasets_operations.py b/tests/integration/sync_datasets_operations.py new file mode 100644 index 0000000..5856353 --- /dev/null +++ b/tests/integration/sync_datasets_operations.py @@ -0,0 +1,356 @@ +""" + +This module contains integration tests that make actual API calls to test +sync_datasets operations in real-world scenarios. + +Usage: + python sync_datasets_operations.py +""" + +import os +import sys + +# Add the root directory to Python path +root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.append(root_dir) + +import time + +from labellerr.client import LabellerrClient +from labellerr.exceptions import LabellerrError + + +def test_sync_datasets( + api_key, + api_secret, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, +): + """ + Test syncing datasets. + + Business scenario: Synchronize dataset files with the backend to ensure + the project has the latest data available for annotation. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID + dataset_id: Dataset ID to sync + path: Path to the data + data_type: Type of data (image, video, audio, document, text) + email_id: Email ID of the user + connection_id: Connection ID + """ + + print("\n" + "=" * 60) + print("TEST: Sync Datasets") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + try: + print(f"\n1. Syncing dataset: {dataset_id}") + print(f"Project ID: {project_id}") + print(f"Data Type: {data_type}") + print(f"Path: {path}") + print(f"Connection ID: {connection_id}") + + result = client.sync_datasets( + client_id=client_id, + project_id=project_id, + dataset_id=dataset_id, + path=path, + data_type=data_type, + email_id=email_id, + connection_id=connection_id, + ) + + print("Dataset sync successful") + print(f"Response: {result}") + + return result + + except LabellerrError as e: + print(f"Error: {str(e)}") + return None + finally: + client.close() + + +def test_sync_datasets_with_different_data_types( + api_key, + api_secret, + client_id, + project_id, + dataset_id, + path, + email_id, + connection_id, +): + """ + Test syncing datasets with different data types. + + Business scenario: Test syncing various data types (image, video, audio, etc.) + to ensure the API handles different file types correctly. + """ + + print("\n" + "=" * 60) + print("TEST: Sync Datasets with Different Data Types") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + results = {} + + data_types = ["image", "video", "audio", "document", "text"] + + for data_type in data_types: + try: + print(f"\n{data_type.upper()} - Syncing dataset...") + result = client.sync_datasets( + client_id=client_id, + project_id=project_id, + dataset_id=dataset_id, + path=path, + data_type=data_type, + email_id=email_id, + connection_id=connection_id, + ) + + print(f"{data_type.upper()} sync successful") + results[data_type] = {"success": True, "result": result} + + # Add delay between requests + time.sleep(1) + + except LabellerrError as e: + print(f"{data_type.upper()} sync failed: {str(e)}") + results[data_type] = {"success": False, "error": str(e)} + + client.close() + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + successful = sum(1 for r in results.values() if r["success"]) + print(f"Successful syncs: {successful}/{len(data_types)}") + + return results + + +def test_sync_datasets_validation(api_key, api_secret): + """ + Test parameter validation for sync datasets. + + Business scenario: Ensure the SDK properly validates input parameters + before making API calls to prevent invalid requests. + """ + print("\n" + "=" * 60) + print("TEST: Sync Datasets Parameter Validation") + print("=" * 60) + + client = LabellerrClient(api_key, api_secret) + + # Test 1: Invalid data_type + print("\n1. Testing invalid data_type...") + try: + client.sync_datasets( + client_id="test_client", + project_id="test_project", + dataset_id="test_dataset", + path="/test/path", + data_type="invalid_type", # Invalid + email_id="test@example.com", + connection_id="test_connection", + ) + print(" Should have raised validation error") + except Exception as e: + print(f"Validation error caught: {str(e)[:80]}...") + + # Test 2: Empty required field + print("\n2. Testing empty required fields...") + try: + client.sync_datasets( + client_id="", # Empty + project_id="test_project", + dataset_id="test_dataset", + path="/test/path", + data_type="image", + email_id="test@example.com", + connection_id="test_connection", + ) + print(" Should have raised validation error") + except Exception as e: + print(f"Validation error caught: {str(e)[:80]}...") + + # Test 3: Missing email format + print("\n3. Testing valid parameters...") + try: + # This will fail at API level but should pass validation + client.sync_datasets( + client_id="test_client", + project_id="test_project", + dataset_id="test_dataset", + path="/test/path", + data_type="image", + email_id="valid@example.com", + connection_id="test_connection", + ) + print(" Validation passed (API call may fail)") + except LabellerrError as e: + print(f"API error (validation passed): {str(e)[:80]}...") + except Exception as e: + print(f"Validation passed, error at API level: {str(e)[:80]}...") + + client.close() + print("\n Validation tests completed") + + +def run_all_tests( + api_key, + api_secret, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, +): + """ + Run all integration tests for sync datasets operations. + + Args: + api_key: API key for authentication + api_secret: API secret for authentication + client_id: Client ID + project_id: Project ID + dataset_id: Dataset ID to sync + path: Path to the data + data_type: Type of data (image, video, audio, document, text) + email_id: Email ID of the user + connection_id: Connection ID + """ + print("\n" + "=" * 80) + print(" SYNC DATASETS OPERATIONS - INTEGRATION TESTS") + print("=" * 80) + print(f"\nClient ID: {client_id}") + print(f"Project ID: {project_id}") + print(f"Dataset ID: {dataset_id}") + print(f"Data Type: {data_type}") + print("\n" + "=" * 80) + + # Test 1: Basic sync + print("\n\n Running Test Suite: BASIC SYNC") + test_sync_datasets( + api_key, + api_secret, + client_id, + project_id, + dataset_id, + path, + data_type, + email_id, + connection_id, + ) + + # Test 2: Validation tests + print("\n\n Running Test Suite: PARAMETER VALIDATION") + test_sync_datasets_validation(api_key, api_secret) + + print("\n" + "=" * 80) + print(" INTEGRATION TESTS COMPLETED") + print("=" * 80) + print("\n") + + +if __name__ == "__main__": + # Import credentials + try: + import cred + + API_KEY = cred.API_KEY + API_SECRET = cred.API_SECRET + CLIENT_ID = cred.CLIENT_ID + PROJECT_ID = cred.PROJECT_ID + + # Additional parameters for sync_datasets + DATASET_ID = getattr(cred, "DATASET_ID", "") + PATH = getattr(cred, "PATH", "/data") + DATA_TYPE = getattr(cred, "DATA_TYPE", "image") + EMAIL_ID = getattr(cred, "EMAIL_ID", "") + CONNECTION_ID = getattr(cred, "CONNECTION_ID", "") + + except (ImportError, AttributeError): + # Fall back to environment variables + API_KEY = os.environ.get("LABELLERR_API_KEY", "") + API_SECRET = os.environ.get("LABELLERR_API_SECRET", "") + CLIENT_ID = os.environ.get("LABELLERR_CLIENT_ID", "") + PROJECT_ID = os.environ.get("LABELLERR_PROJECT_ID", "") + DATASET_ID = os.environ.get("LABELLERR_DATASET_ID", "") + PATH = os.environ.get("LABELLERR_PATH", "/data") + DATA_TYPE = os.environ.get("LABELLERR_DATA_TYPE", "image") + EMAIL_ID = os.environ.get("LABELLERR_EMAIL_ID", "") + CONNECTION_ID = os.environ.get("LABELLERR_CONNECTION_ID", "") + + # Check if credentials are available + if not all([API_KEY, API_SECRET, CLIENT_ID, PROJECT_ID]): + print("\n" + "=" * 80) + print(" ERROR: Missing Credentials") + print("=" * 80) + print("\nPlease provide credentials either by:") + print("1. Setting them in tests/integration/cred.py:") + print(" API_KEY = 'your_api_key'") + print(" API_SECRET = 'your_api_secret'") + print(" CLIENT_ID = 'your_client_id'") + print(" PROJECT_ID = 'your_project_id'") + print(" DATASET_ID = 'your_dataset_id'") + print(" EMAIL_ID = 'user@example.com'") + print(" CONNECTION_ID = 'your_connection_id'") + print(" PATH = '/path/to/data'") + print(" DATA_TYPE = 'image'") + print("\n2. Or setting environment variables:") + print(" export LABELLERR_API_KEY='your_api_key'") + print(" export LABELLERR_API_SECRET='your_api_secret'") + print(" export LABELLERR_CLIENT_ID='your_client_id'") + print(" export LABELLERR_PROJECT_ID='your_project_id'") + print(" export LABELLERR_DATASET_ID='your_dataset_id'") + print(" export LABELLERR_EMAIL_ID='user@example.com'") + print(" export LABELLERR_CONNECTION_ID='your_connection_id'") + print("\n" + "=" * 80) + sys.exit(1) + + # Check if additional sync_datasets parameters are available + if not all([DATASET_ID, EMAIL_ID, CONNECTION_ID]): + print("\n" + "=" * 80) + print(" WARNING: Missing Sync Datasets Parameters") + print("=" * 80) + print("\nRunning validation tests only.") + print("To run full sync tests, provide:") + print(" DATASET_ID, EMAIL_ID, CONNECTION_ID") + print("\n" + "=" * 80) + + # Run only validation tests + print("\n\n Running Test Suite: PARAMETER VALIDATION") + test_sync_datasets_validation(API_KEY, API_SECRET) + sys.exit(0) + + # Run all tests + run_all_tests( + API_KEY, + API_SECRET, + CLIENT_ID, + PROJECT_ID, + DATASET_ID, + PATH, + DATA_TYPE, + EMAIL_ID, + CONNECTION_ID, + ) diff --git a/tests/integration/test_sync_datasets.py b/tests/integration/test_sync_datasets.py new file mode 100644 index 0000000..1655c43 --- /dev/null +++ b/tests/integration/test_sync_datasets.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +""" +Integration tests for sync_datasets API with AWS and GCS. + +This test file contains separate tests for AWS S3 and Google Cloud Storage (GCS) sync operations. +Each test uses its own dataset ID and connection ID. + +Environment variables required (set in root .env file): + - API_KEY, API_SECRET, CLIENT_ID (required for all tests) + +Test data for AWS and GCS is defined within the test class. +""" + +import os +import sys +import unittest +from dataclasses import dataclass +from typing import Optional + +import dotenv + +from labellerr import LabellerrError +from labellerr.client import LabellerrClient + +dotenv.load_dotenv() + + +@dataclass +class SyncDatasetTestCase: + """Test case for sync dataset operations""" + + test_name: str + client_id: str + project_id: str + dataset_id: str + connection_id: str + path: str + email_id: str + data_type: str = "image" + expect_error_substr: Optional[str] = None + expected_success: bool = True + + +class SyncDatasetsIntegrationTests(unittest.TestCase): + """Integration tests for sync_datasets operations""" + + def setUp(self): + """Set up test fixtures""" + self.api_key = os.getenv("API_KEY") + self.api_secret = os.getenv("API_SECRET") + self.client_id = os.getenv("CLIENT_ID") + + if not all([self.api_key, self.api_secret, self.client_id]): + raise ValueError( + "Missing environment variables: API_KEY, API_SECRET, CLIENT_ID" + ) + + self.client = LabellerrClient(self.api_key, self.api_secret, self.client_id) + + # Shared configuration (used by both AWS and GCS tests) + self.project_id = "gabrila_artificial_duck_74237" # Same project for both tests + self.email_id = "dev@labellerr.com" # Same email for both tests + self.data_type = "image" # Same data type for both tests + + # AWS-specific test configuration + self.aws_dataset_id = "b51cf22c-cc57-45dd-a6d5-f2d18ab679a1" + self.aws_connection_id = "96b2950b-2800-4772-ac75-24eff5642ebe" + self.aws_path = "s3://amazon-s3-sync-test/gaurav_test" + + # GCS-specific test configuration - TODO: Fill in these values + self.gcs_dataset_id = "" # TODO: Add your GCS dataset ID + self.gcs_connection_id = "" # TODO: Add your GCS connection ID + self.gcs_path = "gs://" # TODO: Add your GCS path (e.g., gs://bucket/path) + + def test_sync_datasets_aws(self): + """Test syncing datasets from AWS S3""" + print("\n" + "=" * 60) + print("TEST: Sync Datasets - AWS S3") + print("=" * 60) + + try: + print("\n1. Syncing dataset from AWS S3...") + print(f"Project ID: {self.project_id}") + print(f"Dataset ID: {self.aws_dataset_id}") + print(f"Connection ID: {self.aws_connection_id}") + print(f"Path: {self.aws_path}") + print(f"Data Type: {self.data_type}") + print(f"Email ID: {self.email_id}") + + response = self.client.sync_datasets( + client_id=self.client_id, + project_id=self.project_id, + dataset_id=self.aws_dataset_id, + path=self.aws_path, + data_type=self.data_type, + email_id=self.email_id, + connection_id=self.aws_connection_id, + ) + + print("AWS Sync successful") + print(f"Response: {response}") + + self.assertIsInstance(response, dict) + self.assertIsNotNone(response) + + except LabellerrError as e: + self.fail(f"AWS Sync API ERROR: {str(e)}") + except Exception as e: + self.fail(f"AWS Sync ERROR: {type(e).__name__}: {str(e)}") + + def test_sync_datasets_gcs(self): + """Test syncing datasets from Google Cloud Storage (GCS)""" + # Skip if GCS credentials are not provided + if not all( + [ + self.gcs_dataset_id, + self.gcs_connection_id, + self.gcs_path != "gs://", + ] + ): + + print("\n" + "=" * 60) + print("TEST: Sync Datasets - Google Cloud Storage (GCS)") + print("=" * 60) + + try: + print("\n1. Syncing dataset from GCS...") + print(f"Project ID: {self.project_id}") + print(f"Dataset ID: {self.gcs_dataset_id}") + print(f"Connection ID: {self.gcs_connection_id}") + print(f"Path: {self.gcs_path}") + print(f"Data Type: {self.data_type}") + print(f"Email ID: {self.email_id}") + + response = self.client.sync_datasets( + client_id=self.client_id, + project_id=self.project_id, + dataset_id=self.gcs_dataset_id, + path=self.gcs_path, + data_type=self.data_type, + email_id=self.email_id, + connection_id=self.gcs_connection_id, + ) + + print("GCS Sync successful") + print("Response: {response}") + + self.assertIsInstance(response, dict) + self.assertIsNotNone(response) + + except LabellerrError as e: + self.fail(f"GCS Sync API ERROR: {str(e)}") + except Exception as e: + self.fail(f"GCS Sync ERROR: {type(e).__name__}: {str(e)}") + + def test_sync_datasets_with_multiple_data_types(self): + """Test syncing datasets with different data types (AWS)""" + print("\n" + "=" * 60) + print("TEST: Sync Datasets with Multiple Data Types") + print("=" * 60) + + data_types = ["image", "video", "audio", "document", "text"] + + for data_type in data_types: + with self.subTest(data_type=data_type): + print(f"\n Testing with data_type: {data_type}") + + try: + response = self.client.sync_datasets( + client_id=self.client_id, + project_id=self.project_id, + dataset_id=self.aws_dataset_id, + path=self.aws_path, + data_type=data_type, + email_id=self.email_id, + connection_id=self.aws_connection_id, + ) + + print(f"Sync successful for {data_type}") + self.assertIsInstance(response, dict) + + except LabellerrError as e: + # Log error but don't fail - API might restrict certain data types + print(f"ℹ {data_type} sync skipped: {str(e)[:100]}") + + def test_sync_datasets_invalid_connection_id(self): + """Test sync datasets with invalid connection ID""" + print("\n" + "=" * 60) + print("TEST: Sync Datasets with Invalid Connection ID") + print("=" * 60) + + with self.assertRaises((LabellerrError, Exception)) as context: + self.client.sync_datasets( + client_id=self.client_id, + project_id=self.project_id, + dataset_id=self.aws_dataset_id, + path=self.aws_path, + data_type=self.data_type, + email_id=self.email_id, + connection_id="invalid-connection-id", + ) + + print(f"Correctly caught error: {str(context.exception)[:100]}") + + def test_sync_datasets_invalid_dataset_id(self): + """Test sync datasets with invalid dataset ID""" + print("\n" + "=" * 60) + print("TEST: Sync Datasets with Invalid Dataset ID") + print("=" * 60) + + with self.assertRaises((LabellerrError, Exception)) as context: + self.client.sync_datasets( + client_id=self.client_id, + project_id=self.project_id, + dataset_id="00000000-0000-0000-0000-000000000000", + path=self.aws_path, + data_type=self.data_type, + email_id=self.email_id, + connection_id=self.aws_connection_id, + ) + + print(f"Correctly caught error: {str(context.exception)[:100]}") + + def tearDown(self): + """Clean up after each test""" + if hasattr(self, "client"): + self.client.close() + + @classmethod + def setUpClass(cls): + """Set up test suite""" + print("\n" + "=" * 80) + print(" SYNC DATASETS OPERATIONS - INTEGRATION TESTS") + print("=" * 80) + + @classmethod + def tearDownClass(cls): + """Tear down test suite""" + print("\n" + "=" * 80) + print(" INTEGRATION TESTS COMPLETED") + print("=" * 80) + + +def run_sync_datasets_tests(): + """Run all sync datasets integration tests""" + suite = unittest.TestLoader().loadTestsFromTestCase(SyncDatasetsIntegrationTests) + + # Run tests with verbose output + runner = unittest.TextTestRunner(verbosity=2, stream=sys.stdout) + result = runner.run(suite) + + # Return success status + return result.wasSuccessful() + + +if __name__ == "__main__": + """ + Environment Variables Required: + - API_KEY: Your Labellerr API key + - API_SECRET: Your Labellerr API secret + - CLIENT_ID: Your Labellerr client ID + + AWS Configuration (defined in setUp method): + - aws_project_id: Project ID for AWS sync + - aws_dataset_id: Dataset ID for AWS sync + - aws_connection_id: Connection ID for AWS + - aws_path: S3 path (e.g., s3://bucket/path) + - aws_email_id: Email ID for AWS sync + + GCS Configuration (TODO in setUp method): + - gcs_project_id: Project ID for GCS sync + - gcs_dataset_id: Dataset ID for GCS sync + - gcs_connection_id: Connection ID for GCS + - gcs_path: GCS path (e.g., gs://bucket/path) + - gcs_email_id: Email ID for GCS sync + + Run with: + python tests/integration/test_sync_datasets.py + """ + # Check for required environment variables + required_env_vars = ["API_KEY", "API_SECRET", "CLIENT_ID"] + missing_vars = [var for var in required_env_vars if not os.getenv(var)] + + if missing_vars: + print(f"\nMissing required environment variables: {', '.join(missing_vars)}") + print("Please set the following environment variables:") + for var in missing_vars: + print(f" export {var}=your_value") + sys.exit(1) + + # Run the tests + success = run_sync_datasets_tests() + + # Exit with appropriate code + sys.exit(0 if success else 1) diff --git a/tests/labellerr_bulk_assign_integration_case_tests.py b/tests/labellerr_bulk_assign_integration_case_tests.py new file mode 100644 index 0000000..21b3abd --- /dev/null +++ b/tests/labellerr_bulk_assign_integration_case_tests.py @@ -0,0 +1,716 @@ +import os +import sys + +import pytest + +from labellerr.client import LabellerrClient +from labellerr.exceptions import LabellerrError + + +@pytest.fixture(scope="session") +def credentials(): + """Load credentials from cred.py or environment variables""" + # Try to import from cred.py + try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "integration")) + import cred + + return { + "api_key": cred.API_KEY, + "api_secret": cred.API_SECRET, + "client_id": cred.CLIENT_ID, + "project_id": cred.PROJECT_ID, + } + except (ImportError, AttributeError): + # Fall back to environment variables + api_key = os.environ.get("LABELLERR_API_KEY", "") + api_secret = os.environ.get("LABELLERR_API_SECRET", "") + client_id = os.environ.get("LABELLERR_CLIENT_ID", "") + project_id = os.environ.get("LABELLERR_PROJECT_ID", "") + + if not all([api_key, api_secret, client_id, project_id]): + pytest.skip( + "Integration tests require credentials. Set environment variables:\n" + "LABELLERR_API_KEY, LABELLERR_API_SECRET, LABELLERR_CLIENT_ID, LABELLERR_PROJECT_ID\n" + "Or create tests/integration/cred.py with these values." + ) + + return { + "api_key": api_key, + "api_secret": api_secret, + "client_id": client_id, + "project_id": project_id, + } + + +@pytest.fixture +def client(credentials): + """Create a client for integration testing with real API credentials""" + return LabellerrClient(credentials["api_key"], credentials["api_secret"]) + + +@pytest.fixture +def client_id(credentials): + """Get client_id from credentials""" + return credentials["client_id"] + + +@pytest.fixture +def project_id(credentials): + """Get project_id from credentials""" + return credentials["project_id"] + + +def validate_bulk_assign_response(result, file_ids): + """ + Helper function to validate bulk assign API response structure and content. + + Args: + result: The API response dictionary + file_ids: List of file IDs that were attempted to be assigned + + Raises: + AssertionError: If validation fails + """ + assert isinstance(result, dict), "Result should be a dictionary" + + # Check for expected response keys (adjust based on actual API response) + if "response" in result: + response_data = result["response"] + assert isinstance(response_data, dict), "Response data should be a dictionary" + + # Validate status field + if "status" in response_data: + assert response_data["status"] in [ + "success", + "completed", + "pending", + ], f"Expected valid status, got: {response_data['status']}" + + # Validate affected files or count + if "affected_files" in response_data: + assert isinstance( + response_data["affected_files"], (list, int) + ), "Affected files should be list or count" + if isinstance(response_data["affected_files"], list): + assert len(response_data["affected_files"]) <= len( + file_ids + ), "Affected files count should not exceed requested files" + + # Validate message field + if "message" in response_data: + assert isinstance( + response_data["message"], str + ), "Message should be a string" + + # Validate success indicators + if "success" in response_data: + assert isinstance( + response_data["success"], bool + ), "Success flag should be boolean" + + +def validate_list_file_response(result): + """ + Helper function to validate list_file API response structure and content. + + Args: + result: The API response dictionary + + Raises: + AssertionError: If validation fails + """ + assert isinstance(result, dict), "Result should be a dictionary" + + # Check for files in response + if "files" in result: + assert isinstance(result["files"], list), "Files should be a list" + + # Validate individual file structure + for file_item in result["files"]: + assert isinstance(file_item, dict), "Each file should be a dictionary" + # Common file fields + if "id" in file_item: + assert isinstance(file_item["id"], str), "File ID should be a string" + if "status" in file_item: + assert isinstance( + file_item["status"], str + ), "File status should be a string" + + # Check pagination fields + if "next_search_after" in result: + # Cursor can be string or None + assert result["next_search_after"] is None or isinstance( + result["next_search_after"], str + ), "Next search cursor should be string or None" + + if "total" in result: + assert isinstance(result["total"], int), "Total count should be an integer" + assert result["total"] >= 0, "Total count should be non-negative" + + +def get_file_ids_from_project( + client, client_id, project_id, count=5, search_queries=None +): + """ + Helper function to get real file IDs from a project for testing. + + Args: + client: LabellerrClient instance + client_id: Client ID + project_id: Project ID + count: Number of file IDs to retrieve + search_queries: Optional search filters + + Returns: + List of file IDs + + Raises: + pytest.skip: If no files are available in the project + """ + if search_queries is None: + search_queries = {} + + list_result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=count, + ) + validate_list_file_response(list_result) + + files = list_result.get("files", []) + if not files: + pytest.skip( + f"No files available in project for testing (search: {search_queries})" + ) + + file_ids = [f["id"] for f in files[:count] if "id" in f] + if not file_ids: + pytest.skip("No valid file IDs found in project") + + return file_ids + + +class TestBulkAssignBusinessScenarios: + """Integration tests for bulk assign operations in realistic business scenarios""" + + def test_annotation_workflow_assignment(self, client, client_id, project_id): + """ + Test complete workflow: Assign multiple files to annotation team + + Business scenario: + - Project manager receives batch of uploaded images + - Need to assign them to annotation team for labeling + - Bulk operation for efficiency + + Note: This test uses real API credentials and requires actual files in the project. + """ + try: + # Get real file IDs from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=5) + + # Bulk assign files to annotation status + new_status = "annotation" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_quality_review_workflow(self, client, client_id, project_id): + """ + Test workflow: Move completed annotations to review stage + + Business scenario: + - Annotators complete their work + - QA manager needs to bulk-move files to review stage + - Ensures consistent status across batch + + Note: Uses real API with real credentials. + """ + try: + # Get real file IDs from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=4) + + new_status = "review" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_failed_files_reassignment(self, client, client_id, project_id): + """ + Test workflow: Reassign failed files back to annotation + + Business scenario: + - Some files failed quality check + - Need to move them back to annotation status + - Annotators can rework these files + + Note: Uses real API with real credentials. + """ + try: + # Get real file IDs from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=3) + + new_status = "rework" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_completion_workflow(self, client, client_id, project_id): + """ + Test workflow: Mark reviewed files as completed + + Business scenario: + - Final review is complete + - Project manager marks files as done + - Ready for export and delivery to client + + Note: Uses real API with real credentials. + """ + try: + # Get real file IDs from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=6) + + new_status = "completed" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_single_file_bulk_operation(self, client, client_id, project_id): + """ + Test workflow: Bulk operation with single file + + Business scenario: + - Sometimes need to change status of just one file + - Using bulk API for consistency + - Should work same as multi-file operation + + Note: Uses real API with real credentials. + """ + try: + # Get a single real file ID from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=1) + + new_status = "urgent_review" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_large_batch_assignment(self, client, client_id, project_id): + """ + Test workflow: Bulk assign large batch of files + + Business scenario: + - Processing large dataset upload + - Need to assign 50+ files efficiently + - Testing system scalability + + Note: Uses real API with real credentials. Tries to get up to 50 files. + """ + try: + # Try to get a large batch of files (up to 50) + file_ids = get_file_ids_from_project( + client, client_id, project_id, count=50 + ) + + new_status = "pending_annotation" + result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=new_status, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(result, file_ids) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + +class TestListFileBusinessScenarios: + """Integration tests for list file operations in realistic business scenarios""" + + def test_search_by_status(self, client, client_id, project_id): + """ + Test workflow: Find all files in annotation status + + Business scenario: + - Team lead wants to see all files currently being annotated + - Filter by status to track progress + - Plan resource allocation + + Note: Uses real API with real credentials. + """ + search_queries = {"status": "annotation"} + + try: + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=20, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_search_with_pagination(self, client, client_id, project_id): + """ + Test workflow: Paginate through large file list + + Business scenario: + - Project has 1000+ files + - Need to load them in pages for performance + - Use pagination cursor to navigate + + Note: Uses real API with real credentials. + """ + search_queries = {} + + try: + # First page + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=50, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + + # Get next page if cursor exists + next_cursor = result.get("next_search_after") + if next_cursor: + result_page_2 = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=50, + next_search_after=next_cursor, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result_page_2) + + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_search_with_date_range(self, client, client_id, project_id): + """ + Test workflow: Find files uploaded in specific date range + + Business scenario: + - Manager wants to review this week's uploads + - Filter by creation date range + - Generate weekly progress report + + Note: Uses real API with real credentials. + """ + search_queries = { + "created_at": {"gte": "2024-01-01", "lte": "2024-01-07"}, + "status": "review", + } + + try: + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=100, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_search_with_multiple_filters(self, client, client_id, project_id): + """ + Test workflow: Complex search with multiple criteria + + Business scenario: + - Quality manager needs specific subset of files + - Must match multiple criteria: status, assignee, date + - Precise targeting for audit purposes + + Note: Uses real API with real credentials. + """ + search_queries = { + "status": "completed", + } + + try: + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=25, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_search_pending_files(self, client, client_id, project_id): + """ + Test workflow: Find unassigned files needing attention + + Business scenario: + - New files uploaded but not yet assigned + - Project coordinator identifies work backlog + - Prepares batch for assignment + + Note: Uses real API with real credentials. + """ + search_queries = {"status": "pending"} + + try: + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=100, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_search_with_custom_page_size(self, client, client_id, project_id): + """ + Test workflow: Adjust page size based on use case + + Business scenario: + - Different views need different page sizes + - Dashboard preview: 10 items + - Bulk operations: 100+ items + - Testing flexible pagination + + Note: Uses real API with real credentials. + """ + search_queries = {} + + try: + # Small page for preview + result_preview = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=10, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result_preview) + + # Large page for bulk operations + result_bulk = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=200, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result_bulk) + + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_empty_search_results(self, client, client_id, project_id): + """ + Test workflow: Handle searches with no results + + Business scenario: + - Search for files that don't exist + - System should handle gracefully + - No errors for empty results + + Note: Uses real API with real credentials. + """ + search_queries = {"status": "failed"} + + try: + result = client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + size=10, + ) + # Positive validation: verify the result structure and content + validate_list_file_response(result) + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + +class TestIntegratedWorkflow: + """Integration tests combining list and bulk assign operations""" + + def test_list_and_bulk_assign_workflow(self, client, client_id, project_id): + """ + Test complete workflow: Search then bulk assign + + Business scenario: + - Find all pending files + - Bulk assign them to annotation team + - Common workflow pattern + + Note: Uses real API with real credentials and actual files. + """ + try: + # Step 1: Get real file IDs from the project + file_ids = get_file_ids_from_project(client, client_id, project_id, count=3) + + # Step 2: Bulk assign to annotation + assign_result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status="annotation", + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(assign_result, file_ids) + + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + def test_progressive_assignment_workflow(self, client, client_id, project_id): + """ + Test workflow: Progressive assignment through stages + + Business scenario: + - Files move through annotation pipeline + - List files at each stage + - Bulk assign to next stage + - Complete workflow automation + + Note: Uses real API with real credentials and actual files. + """ + stages = ["annotation", "review", "qa", "completed"] + + try: + for i, stage in enumerate(stages[:-1]): + # Get real files for each stage transition + file_ids = get_file_ids_from_project( + client, client_id, project_id, count=3 + ) + + # Move files to next stage + next_stage = stages[i + 1] + assign_result = client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status=next_stage, + ) + # Positive validation: verify the result structure and content + validate_bulk_assign_response(assign_result, file_ids) + + except LabellerrError as e: + pytest.fail(f"Integration test failed with API error: {str(e)}") + + +class TestErrorScenarios: + """Integration tests for realistic error scenarios""" + + def test_authentication_failure(self, client_id): + """ + Test authentication failure scenario + + Note: Uses invalid credentials to test error handling. + """ + # Create client with invalid credentials + invalid_client = LabellerrClient("invalid_api_key", "invalid_api_secret") + project_id = "test_project" + file_ids = ["file1.jpg"] + + with pytest.raises(LabellerrError) as exc_info: + invalid_client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status="annotation", + ) + + # Verify it's an authentication error + error_str = str(exc_info.value).lower() + assert any( + word in error_str + for word in ["auth", "invalid", "unauthorized", "credentials"] + ) + + def test_project_not_found(self, client, client_id): + """ + Test project not found scenario + + Note: Uses real API with valid credentials but nonexistent project. + """ + project_id = "nonexistent_project_xyz_12345" + search_queries = {"status": "completed"} + + with pytest.raises(LabellerrError) as exc_info: + client.list_file( + client_id=client_id, + project_id=project_id, + search_queries=search_queries, + ) + + # Verify it's a project not found error + error_str = str(exc_info.value).lower() + assert any( + word in error_str for word in ["project", "not found", "does not exist"] + ) + + def test_invalid_file_ids(self, client, client_id, project_id): + """ + Test bulk assign with nonexistent file IDs + + Note: Uses real API with valid credentials but invalid file IDs. + """ + file_ids = ["nonexistent_file_1_xyz", "nonexistent_file_2_xyz"] + + with pytest.raises(LabellerrError) as exc_info: + client.bulk_assign_files( + client_id=client_id, + project_id=project_id, + file_ids=file_ids, + new_status="annotation", + ) + + # Verify it's a file not found error + error_str = str(exc_info.value).lower() + assert any( + word in error_str + for word in ["file", "not found", "does not exist", "invalid"] + ) diff --git a/tests/labellerr_integration_case_tests.py b/tests/labellerr_integration_case_tests.py new file mode 100644 index 0000000..95fbacd --- /dev/null +++ b/tests/labellerr_integration_case_tests.py @@ -0,0 +1,1796 @@ +import json +import os +import sys +import tempfile +import time +import unittest +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import dotenv +from pydantic import ValidationError + +from labellerr import LabellerrError +from labellerr.client import LabellerrClient + +dotenv.load_dotenv() + + +@dataclass +class AttachDetachTestCase: + """Test case for attach/detach dataset operations""" + + test_name: str + client_id: str + project_id: str + dataset_id: str + expect_error_substr: Optional[str] = None + expected_success: bool = True + + +@dataclass +class MultimodalIndexingTestCase: + """Test case for multimodal indexing operations""" + + test_name: str + client_id: str + dataset_id: str + is_multimodal: bool = True + expect_error_substr: Optional[str] = None + expected_success: bool = True + + +@dataclass +class AWSConnectionTestCase: + test_name: str + client_id: str + access_key: str + secret_key: str + s3_path: str + data_type: str + name: str + description: str + connection_type: str = "import" + expect_error_substr: str | list[str] | None = None + + +@dataclass +class GCSConnectionTestCase: + test_name: str + client_id: str + cred_file_content: str + gcs_path: str + data_type: str + name: str + description: str + connection_type: str = "import" + expect_error_substr: str | list[str] | None = None + + +@dataclass +class UserManagementTestCase: + """Test case for user management operations""" + + test_name: str + client_id: str + project_id: str + email_id: str + first_name: str + last_name: str + user_id: str = None + role_id: str = None + new_role_id: str = None + expect_error_substr: str | None = None + expected_success: bool = True + + +@dataclass +class UserWorkflowTestCase: + """Test case for complete user workflow operations""" + + test_name: str + client_id: str + project_id: str + email_id: str + first_name: str + last_name: str + user_id: str + roles: List[Dict[str, Any]] + projects: List[str] + expect_error_substr: str | None = None + expected_success: bool = True + + +class LabelerIntegrationTests(unittest.TestCase): + + def setUp(self): + + self.api_key = os.getenv("API_KEY") + self.api_secret = os.getenv("API_SECRET") + self.client_id = os.getenv("CLIENT_ID") + self.test_email = os.getenv("CLIENT_EMAIL") + self.connector_video_creds_aws = os.getenv("AWS_CONNECTION_VIDEO") + self.connector_image_creds_aws = os.getenv("AWS_CONNECTION_IMAGE") + self.connector_image_creds_gcs = os.getenv("GCS_CONNECTION_IMAGE") + self.connector_video_creds_gcs = os.getenv("GCS_CONNECTION_VIDEO") + + # Configurable test IDs for attach/detach operations + self.test_project_id = os.getenv( + "TEST_PROJECT_ID", "sisely_serious_tarantula_26824" + ) + self.test_dataset_id = os.getenv( + "TEST_DATASET_ID", "bfd09b6a-a593-4246-82f7-505a497a887c" + ) + + if ( + self.api_key == "" + or self.api_secret == "" + or self.client_id == "" + or self.test_email == "" + or self.connector_video_creds_aws == "" + or self.connector_image_creds_aws == "" + ): + + raise ValueError( + "missing environment variables: " + "LABELLERR_API_KEY, LABELLERR_API_SECRET, LABELLERR_CLIENT_ID, LABELLERR_TEST_EMAIL, AWS_CONNECTION_VIDEO, AWS_CONNECTION_IMAGE" + ) + + self.client = LabellerrClient(self.api_key, self.api_secret) + + self.test_project_name = f"SDK_Test_Project_{int(time.time())}" + self.test_dataset_name = f"SDK_Test_Dataset_{int(time.time())}" + + # Sample annotation guide as per documentation requirements + self.annotation_guide = [ + { + "question": "What objects do you see?", + "option_type": "select", + "options": ["cat", "dog", "car", "person", "other"], + }, + { + "question": "Image quality rating", + "option_type": "radio", + "options": ["excellent", "good", "fair", "poor"], + }, + ] + + self.rotation_config = { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, + } + + def test_complete_project_creation_workflow(self): + + test_files = [] + try: + for i in range(3): + temp_file = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + temp_file.write(b"fake_image_data_" + str(i).encode()) + temp_file.close() + test_files.append(temp_file.name) + + # Step 1: Prepare project payload with all required parameters + project_payload = { + "client_id": self.client_id, + "dataset_name": self.test_dataset_name, + "dataset_description": "Test dataset for SDK integration testing", + "data_type": "image", + "created_by": self.test_email, + "project_name": self.test_project_name, + "autolabel": False, + "files_to_upload": test_files, + "annotation_guide": self.annotation_guide, + "rotation_config": self.rotation_config, + } + + # Step 2: Execute complete project creation workflow + + result = self.client.initiate_create_project(project_payload) + + # Step 3: Validate the workflow execution + self.assertIsInstance( + result, dict, "Project creation should return a dictionary" + ) + self.assertEqual( + result.get("status"), "success", "Project creation should be successful" + ) + self.assertIn("message", result, "Result should contain a success message") + self.assertIn("project_id", result, "Result should contain project_id") + + self.created_project_id = result.get("project_id") + self.created_dataset_name = self.test_dataset_name + + except LabellerrError as e: + self.fail(f"Project creation failed with LabellerrError: {e}") + except Exception as e: + self.fail(f"Project creation failed with unexpected error: {e}") + finally: + for file_path in test_files: + try: + os.unlink(file_path) + except OSError: + pass + + def test_project_creation_missing_client_id(self): + """Test that project creation fails when client_id is missing""" + base_payload = { + "dataset_name": "test_dataset", + "dataset_description": "test description", + "data_type": "image", + "created_by": "test@example.com", + "project_name": "test_project", + "autolabel": False, + "files_to_upload": [], + "annotation_guide": self.annotation_guide, + } + + with self.assertRaises(LabellerrError) as context: + self.client.initiate_create_project(base_payload) + + self.assertIn("Required parameter client_id is missing", str(context.exception)) + + def test_project_creation_invalid_email(self): + """Test that project creation fails with invalid email format""" + base_payload = { + "client_id": self.client_id, + "dataset_name": "test_dataset", + "dataset_description": "test description", + "data_type": "image", + "created_by": "invalid-email", + "project_name": "test_project", + "autolabel": False, + "files_to_upload": [], + "annotation_guide": self.annotation_guide, + } + + with self.assertRaises(LabellerrError) as context: + self.client.initiate_create_project(base_payload) + + self.assertIn("Please enter email id in created_by", str(context.exception)) + + def test_project_creation_invalid_data_type(self): + """Test that project creation fails with invalid data type""" + base_payload = { + "client_id": self.client_id, + "dataset_name": "test_dataset", + "dataset_description": "test description", + "data_type": "invalid_type", + "created_by": "test@example.com", + "project_name": "test_project", + "autolabel": False, + "files_to_upload": [], + "annotation_guide": self.annotation_guide, + } + + with self.assertRaises(LabellerrError) as context: + self.client.initiate_create_project(base_payload) + + self.assertIn("Invalid data_type", str(context.exception)) + + def test_project_creation_missing_dataset_name(self): + """Test that project creation fails when dataset_name is missing""" + base_payload = { + "client_id": self.client_id, + "dataset_description": "test description", + "data_type": "image", + "created_by": "test@example.com", + "project_name": "test_project", + "autolabel": False, + "files_to_upload": [], + "annotation_guide": self.annotation_guide, + } + + with self.assertRaises(LabellerrError) as context: + self.client.initiate_create_project(base_payload) + + self.assertIn( + "Required parameter dataset_name is missing", str(context.exception) + ) + + def test_project_creation_missing_annotation_guide(self): + """Test that project creation fails when annotation guide is missing""" + base_payload = { + "client_id": self.client_id, + "dataset_name": "test_dataset", + "dataset_description": "test description", + "data_type": "image", + "created_by": "test@example.com", + "project_name": "test_project", + "autolabel": False, + "files_to_upload": [], + } + + with self.assertRaises(LabellerrError) as context: + self.client.initiate_create_project(base_payload) + + self.assertIn( + "Please provide either annotation guide or annotation template id", + str(context.exception), + ) + + def test_create_image_classification_project(self): + """Test creating an image classification project""" + test_files = [] + try: + for ext in [".jpg", ".png"]: + temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False) + temp_file.write(b"fake_image_data") + temp_file.close() + test_files.append(temp_file.name) + + annotation_guide = [ + { + "question": "Test question 1", + "option_type": "select", + "options": ["option1", "option2", "option3"], + }, + { + "question": "Test question 2", + "option_type": "radio", + "options": ["option1", "option2", "option3"], + }, + ] + + project_payload = { + "client_id": self.client_id, + "dataset_name": f"SDK_Test_image_{int(time.time())}", + "dataset_description": "Test dataset for Image Classification Project", + "data_type": "image", + "created_by": self.test_email, + "project_name": f"SDK_Test_Project_image_{int(time.time())}", + "autolabel": False, + "files_to_upload": test_files, + "annotation_guide": annotation_guide, + "rotation_config": self.rotation_config, + } + + result = self.client.initiate_create_project(project_payload) + + self.assertIsInstance(result, dict) + self.assertEqual(result.get("status"), "success") + print(" Image Classification Project created successfully") + + finally: + for file_path in test_files: + try: + os.unlink(file_path) + except OSError: + pass + + def test_create_document_processing_project(self): + """Test creating a document processing project""" + test_files = [] + try: + temp_file = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + temp_file.write(b"fake_document_data") + temp_file.close() + test_files.append(temp_file.name) + + annotation_guide = [ + {"question": "Test question 1", "option_type": "input", "options": []}, + { + "question": "Test question 2", + "option_type": "boolean", + "options": ["Yes", "No"], + }, + ] + + project_payload = { + "client_id": self.client_id, + "dataset_name": f"SDK_Test_document_{int(time.time())}", + "dataset_description": "Test dataset for Document Processing Project", + "data_type": "document", + "created_by": self.test_email, + "project_name": f"SDK_Test_Project_document_{int(time.time())}", + "autolabel": False, + "files_to_upload": test_files, + "annotation_guide": annotation_guide, + "rotation_config": self.rotation_config, + } + + result = self.client.initiate_create_project(project_payload) + + self.assertIsInstance(result, dict) + self.assertEqual(result.get("status"), "success") + print(" Document Processing Project created successfully") + + finally: + for file_path in test_files: + try: + os.unlink(file_path) + except OSError: + pass + + def test_pre_annotation_upload_workflow(self): + annotation_data = { + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "bbox": [100, 100, 200, 200], + "area": 40000, + "iscrowd": 0, + } + ], + "images": [ + {"id": 1, "width": 640, "height": 480, "file_name": "test_image.jpg"} + ], + "categories": [{"id": 1, "name": "person", "supercategory": "human"}], + } + + temp_annotation_file = None + try: + temp_annotation_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + json.dump(annotation_data, temp_annotation_file) + temp_annotation_file.close() + + test_project_id = "sunny_tough_blackbird_40468" + annotation_format = "coco_json" + + if hasattr(self, "created_project_id") and self.created_project_id: + actual_project_id = self.created_project_id + else: + actual_project_id = test_project_id + try: + result = self.client._upload_preannotation_sync( + project_id=actual_project_id, + client_id=self.client_id, + annotation_format=annotation_format, + annotation_file=temp_annotation_file.name, + ) + + self.assertIsInstance( + result, dict, "Upload should return a dictionary" + ) + self.assertIn("response", result, "Result should contain response") + + except Exception as api_error: + raise api_error + + except LabellerrError as e: + self.fail(f"Pre-annotation upload failed with LabellerrError: {e}") + except Exception as e: + self.fail(f"Pre-annotation upload failed with unexpected error: {e}") + finally: + if temp_annotation_file: + try: + os.unlink(temp_annotation_file.name) + except OSError: + pass + + def test_pre_annotation_invalid_format(self): + """Test that pre_annotation upload fails with invalid annotation format""" + with self.assertRaises(LabellerrError) as context: + self.client._upload_preannotation_sync( + project_id="test-project", + client_id=self.client_id, + annotation_format="invalid_format", + annotation_file="test.json", + ) + + self.assertIn("Invalid annotation_format", str(context.exception)) + + def test_pre_annotation_file_not_found(self): + """Test that pre_annotation upload fails when file doesn't exist""" + with self.assertRaises(LabellerrError) as context: + self.client._upload_preannotation_sync( + project_id="test-project", + client_id=self.client_id, + annotation_format="json", + annotation_file="non_existent_file.json", + ) + + self.assertIn("File not found", str(context.exception)) + + def test_pre_annotation_wrong_file_extension(self): + """Test that pre_annotation upload fails with wrong file extension for COCO format""" + temp_file = None + try: + temp_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False) + temp_file.write(b"test content") + temp_file.close() + + with self.assertRaises(LabellerrError) as context: + self.client._upload_preannotation_sync( + project_id="test-project", + client_id=self.client_id, + annotation_format="coco_json", + annotation_file=temp_file.name, + ) + + self.assertIn( + "For coco_json annotation format, the file must have a .json extension", + str(context.exception), + ) + + finally: + if temp_file: + try: + os.unlink(temp_file.name) + except OSError: + pass + + def test_pre_annotation_upload_coco_json(self): + """Test uploading pre annotations in COCO JSON format""" + temp_annotation_file = None + try: + sample_data = { + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "bbox": [0, 0, 100, 100], + } + ], + "images": [ + {"id": 1, "file_name": "test.jpg", "width": 640, "height": 480} + ], + "categories": [{"id": 1, "name": "test", "supercategory": "object"}], + } + + temp_annotation_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + json.dump(sample_data, temp_annotation_file) + temp_annotation_file.close() + + # Get a valid image project ID from the system (COCO JSON is for images) + test_project_id = None + if hasattr(self, "created_project_id") and self.created_project_id: + test_project_id = self.created_project_id + else: + # Try to get an image-type project + try: + projects = self.client.get_all_project_per_client_id(self.client_id) + if projects.get("response") and len(projects["response"]) > 0: + # Look for a project with data_type 'image' + for project in projects["response"]: + # COCO JSON is typically for image annotation projects + if "image" in project.get("project_name", "").lower(): + test_project_id = project["project_id"] + break + # If no image project found, skip the test + if not test_project_id: + test_project_id = projects["response"][0]["project_id"] + except Exception: + pass + + if not test_project_id: + self.skipTest( + "No valid project available for pre-annotation upload test" + ) + + result = self.client._upload_preannotation_sync( + project_id=test_project_id, + client_id=self.client_id, + annotation_format="coco_json", + annotation_file=temp_annotation_file.name, + ) + + self.assertIsInstance(result, dict) + self.assertIn("response", result) + + finally: + if temp_annotation_file: + try: + os.unlink(temp_annotation_file.name) + except OSError: + pass + + def test_pre_annotation_upload_json(self): + """Test uploading pre_annotations in JSON format with timeout protection + + Note: This test requires a valid project ID. It will use: + 1. self.created_project_id if test_complete_project_creation_workflow ran first + 2. Otherwise, self.test_project_id from environment variable TEST_PROJECT_ID + + Set TEST_PROJECT_ID environment variable to a valid project ID if needed. + """ + import signal + + def timeout_handler(signum, frame): + raise TimeoutError( + "Test timed out after 60 seconds - API job polling may be stuck" + ) + + temp_annotation_file = None + # Set a 60-second timeout for this test + old_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(60) + + try: + sample_data = { + "labels": [ + { + "image": "test.jpg", + "annotations": [{"label": "cat", "confidence": 0.95}], + } + ] + } + + temp_annotation_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + json.dump(sample_data, temp_annotation_file) + temp_annotation_file.close() + + # Use created_project_id from test_complete_project_creation_workflow if available, + # otherwise use test_project_id from environment + test_project_id = ( + getattr(self, "created_project_id", None) or self.test_project_id + ) + + print(f"Attempting to upload pre-annotation to project: {test_project_id}") + print("Note: This test has a 60-second timeout to prevent hanging") + + try: + result = self.client._upload_preannotation_sync( + project_id=test_project_id, + client_id=self.client_id, + annotation_format="json", + annotation_file=temp_annotation_file.name, + ) + + self.assertIsInstance(result, dict) + print("Pre-annotation upload successful") + except TimeoutError as e: + self.fail( + f"Test timed out: {e}\n" + f"The SDK's job status polling has an infinite loop with no timeout. " + f"Consider fixing labellerr/client.py::preannotation_job_status_async to add max retries." + ) + except LabellerrError as e: + error_str = str(e) + # Handle common API errors gracefully + if ( + "Invalid project_id" in error_str + or "not found" in error_str.lower() + ): + self.skipTest( + f"Skipping test - invalid project_id '{test_project_id}'. " + f"Set TEST_PROJECT_ID environment variable to a valid project ID." + ) + elif "did not complete after" in error_str and "retries" in error_str: + # Job stuck in queue or not processing + self.skipTest( + f"Skipping test - pre-annotation job did not complete: {error_str[:200]}. " + f"The API job queue may be stuck or the project may not support pre-annotations." + ) + elif "timeout" in error_str.lower() or "timed out" in error_str.lower(): + self.fail(f"API request timed out: {error_str[:200]}") + elif ( + "403" in error_str + or "401" in error_str + or "Not Authorized" in error_str + ): + self.skipTest( + f"Skipping test - authentication/authorization issue: {error_str[:200]}" + ) + else: + # Re-raise other errors + raise + except Exception as e: + error_str = str(e) + if "timeout" in error_str.lower() or "timed out" in error_str.lower(): + self.fail(f"Request timed out: {error_str[:200]}") + else: + raise + + finally: + # Cancel the alarm + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + + if temp_annotation_file: + try: + os.unlink(temp_annotation_file.name) + except OSError: + pass + + def test_data_set_connection_aws(self): + + # Read per-type AWS secrets from env (JSON strings): AWS_CONNECTION_IMAGE, AWS_CONNECTION_VIDEO + image_secret_json = os.getenv("AWS_CONNECTION_IMAGE") + video_secret_json = os.getenv("AWS_CONNECTION_VIDEO") + + def _parse_secret(env_json: str): + if not env_json: + return {} + try: + return json.loads(env_json) + except Exception as ex: + return ex + + image_secret = _parse_secret(image_secret_json) + video_secret = _parse_secret(video_secret_json) + + image_access_key = image_secret.get("access_key") + image_secret_key = image_secret.get("secret_key") + image_s3_path = image_secret.get("s3_path") + + video_access_key = video_secret.get("access_key") + video_secret_key = video_secret.get("secret_key") + video_s3_path = video_secret.get("s3_path") + + cases: list[AWSConnectionTestCase] = [ + AWSConnectionTestCase( + test_name="Missing credentials", + client_id=self.client_id, + access_key="", + secret_key="", + s3_path="s3://bucket/path", + data_type="image", + name="aws_invalid_connection_test", + description="missing_secrets", + expect_error_substr=[ + # Common Pydantic v2/v1 variants + "at least 1 character", + "at least 1 characters", + "ensure this value has at least 1 characters", + "String should have at least 1 characters", + "must be at least 1 character", + "Input should be at least 1 character", + ], + ), + AWSConnectionTestCase( + test_name="Valid image import", + client_id=self.client_id, + access_key=image_access_key, + secret_key=image_secret_key, + s3_path=image_s3_path, + data_type="image", + name="aws_connection_image", + description="test_description", + ), + AWSConnectionTestCase( + test_name="Valid video import", + client_id=self.client_id, + access_key=video_access_key, + secret_key=video_secret_key, + s3_path=video_s3_path, + data_type="video", + name="aws_connection_video", + description="test_description", + ), + ] + + for case in cases: + with self.subTest(test_name=case.test_name): + if case.expect_error_substr is not None: + # Pydantic validation errors raise ValidationError, API errors raise LabellerrError + expected_subst = ( + case.expect_error_substr + if isinstance(case.expect_error_substr, list) + else [case.expect_error_substr] + ) + validation_markers = [ + "at least 1", + "ensure this value has at least", + "String should have at least", + "Input should be at least", + "GCS credential file not found", + ] + error_type = ( + ValidationError + if any( + any(vm in s for vm in validation_markers) + for s in expected_subst + ) + else LabellerrError + ) + with self.assertRaises(error_type) as ctx: + self.client.create_aws_connection( + client_id=case.client_id, + aws_access_key=case.access_key, + aws_secrets_key=case.secret_key, + s3_path=case.s3_path, + data_type=case.data_type, + name=case.name, + description=case.description, + connection_type=case.connection_type, + ) + if expected_subst: + exc_str = str(ctx.exception) + self.assertTrue( + any(sub in exc_str for sub in expected_subst), + msg=f"Expected one of {expected_subst} in error, got: {exc_str}", + ) + else: + try: + result = self.client.create_aws_connection( + client_id=case.client_id, + aws_access_key=case.access_key, + aws_secrets_key=case.secret_key, + s3_path=case.s3_path, + data_type=case.data_type, + name=case.name, + description=case.description, + connection_type=case.connection_type, + ) + self.assertIsInstance(result, dict) + self.assertIn("response", result) + connection_id = result["response"].get("connection_id") + self.assertIsNotNone(connection_id) + + list_result = self.client.list_connection( + client_id=case.client_id, + connection_type=case.connection_type, + connector="s3", + ) + self.assertIsInstance(list_result, dict) + self.assertIn("response", list_result) + + del_result = self.client.delete_connection( + client_id=case.client_id, connection_id=connection_id + ) + self.assertIsInstance(del_result, dict) + self.assertIn("response", del_result) + except LabellerrError as e: + error_str = str(e) + # Skip test if API is having issues (500 errors) + if "500" in error_str or "Max retries exceeded" in error_str: + self.skipTest( + f"API unavailable for test '{case.test_name}': {error_str[:100]}" + ) + else: + raise + + def test_data_set_connection_gcs(self): + # Read per-type GCS secrets from env (JSON strings): GCS_CONNECTION_IMAGE, GCS_CONNECTION_VIDEO + image_secret_json = os.getenv("GCS_CONNECTION_IMAGE") + video_secret_json = os.getenv("GCS_CONNECTION_VIDEO") + + def _parse_secret(env_json: str): + if not env_json: + return {} + try: + return json.loads(env_json) + except Exception: + return {} + + image_secret = _parse_secret(image_secret_json) + video_secret = _parse_secret(video_secret_json) + + image_cred_file = image_secret.get("cred_file") + image_gcs_path = image_secret.get("gcs_path") + + video_cred_file = video_secret.get("cred_file") + video_gcs_path = video_secret.get("gcs_path") + + cases: list[GCSConnectionTestCase] = [ + GCSConnectionTestCase( + test_name="Missing credential file", + client_id=self.client_id, + cred_file_content="", + gcs_path="gs://bucket/path", + data_type="image", + name="gcs_invalid_connection_test", + description="missing_cred_file", + expect_error_substr=[ + "GCS credential file not found", + "file does not exist", + "path is not a file", + "No such file or directory", + ], + ), + ] + + # Only add valid cases if credentials are available + if image_cred_file and image_gcs_path: + cases.append( + GCSConnectionTestCase( + test_name="Valid image import", + client_id=self.client_id, + cred_file_content=image_cred_file, + gcs_path=image_gcs_path, + data_type="image", + name="gcs_connection_image", + description="test_description", + ) + ) + + if video_cred_file and video_gcs_path: + cases.append( + GCSConnectionTestCase( + test_name="Valid video import", + client_id=self.client_id, + cred_file_content=video_cred_file, + gcs_path=video_gcs_path, + data_type="video", + name="gcs_connection_video", + description="test_description", + ) + ) + + for case in cases: + with self.subTest(test_name=case.test_name): + # Skip valid cases if credentials are not available + if case.expect_error_substr is None and ( + not case.cred_file_content or not case.gcs_path + ): + self.skipTest( + f"Skipping {case.test_name}: GCS credentials not available in environment" + ) + + temp_created_path = None + if case.expect_error_substr is not None: + # Pydantic validation errors (like file not found) raise ValidationError + expected_substrs = ( + case.expect_error_substr + if isinstance(case.expect_error_substr, list) + else [case.expect_error_substr] + ) + validation_markers = [ + "GCS credential file not found", + "file does not exist", + "path is not a file", + "No such file or directory", + ] + error_type = ( + ValidationError + if any( + any(vm in s for vm in validation_markers) + for s in expected_substrs + ) + else LabellerrError + ) + with self.assertRaises(error_type) as ctx: + self.client.create_gcs_connection( + client_id=case.client_id, + gcs_cred_file=case.cred_file_content, + gcs_path=case.gcs_path, + data_type=case.data_type, + name=case.name, + description=case.description, + connection_type=case.connection_type, + ) + if expected_substrs: + exc_str = str(ctx.exception) + self.assertTrue( + any(sub in exc_str for sub in expected_substrs), + msg=f"Expected one of {expected_substrs} in error, got: {exc_str}", + ) + else: + tf = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + try: + # Support both JSON string and already-parsed dict for creds + if isinstance(case.cred_file_content, (dict, list)): + parsed = case.cred_file_content + elif isinstance( + case.cred_file_content, (str, bytes, bytearray) + ): + parsed = json.loads(case.cred_file_content) + else: + raise TypeError( + "Unsupported credential content type; expected str/bytes/dict/list" + ) + tf.write(json.dumps(parsed)) + tf.flush() + except Exception as e: + raise e + finally: + try: + tf.close() + except Exception: + pass + temp_created_path = tf.name + result = self.client.create_gcs_connection( + client_id=case.client_id, + gcs_cred_file=temp_created_path, + gcs_path=case.gcs_path, + data_type=case.data_type, + name=case.name, + description=case.description, + connection_type=case.connection_type, + ) + self.assertIsInstance(result, dict) + self.assertIn("response", result) + connection_id = result["response"].get("connection_id") + self.assertIsNotNone(connection_id) + + list_result = self.client.list_connection( + client_id=case.client_id, + connection_type=case.connection_type, + connector="gcs", + ) + self.assertIsInstance(list_result, dict) + self.assertIn("response", list_result) + + del_result = self.client.delete_connection( + client_id=case.client_id, connection_id=connection_id + ) + self.assertIsInstance(del_result, dict) + self.assertIn("response", del_result) + if temp_created_path: + try: + os.unlink(temp_created_path) + except OSError: + pass + + def test_attach_detach_dataset_workflow(self): + """Comprehensive test for single and batch attach/detach workflows - always detach first to ensure consistent state""" + # ========== SINGLE DATASET OPERATIONS ========== + print("\n=== Testing Single Dataset Operations ===") + + # Step 1: Detach single dataset first to get to a known state + print(f"Step 1: Detaching single dataset {self.test_dataset_id}...") + try: + single_detach_result = self.client.initiate_detach_dataset_from_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id=self.test_dataset_id, + ) + self.assertIsInstance(single_detach_result, dict) + self.assertIn("response", single_detach_result) + print("Single dataset detached successfully") + except Exception as e: + # If detach fails, dataset might not be attached - that's okay, continue + print( + f" Single detach skipped (dataset might not be attached): {str(e)[:100]}" + ) + + # Step 2: Attach single dataset + print("Step 2: Attaching single dataset...") + try: + single_attach_result = self.client.initiate_attach_dataset_to_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id=self.test_dataset_id, + ) + self.assertIsInstance(single_attach_result, dict) + self.assertIn("response", single_attach_result) + print("Single dataset attached successfully") + except LabellerrError as e: + # Handle "already attached" as a success case + error_str = str(e) + if "already been attached" in error_str or "already attached" in error_str: + print("Single dataset already attached (treating as success)") + else: + self.fail(f"Failed to attach single dataset: {e}") + except Exception as e: + self.fail(f"Failed to attach single dataset: {e}") + + # ========== BATCH DATASET OPERATIONS ========== + print("\n=== Testing Batch Dataset Operations ===") + test_dataset_ids = [self.test_dataset_id] + + # Step 3: Detach batch datasets first to get to a known state + print(f"Step 3: Detaching batch datasets {test_dataset_ids}...") + try: + batch_detach_result = self.client.initiate_detach_datasets_from_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_ids=test_dataset_ids, + ) + self.assertIsInstance(batch_detach_result, dict) + self.assertIn("response", batch_detach_result) + print("Batch datasets detached successfully") + except Exception as e: + print(f"Batch detach skipped: {str(e)[:100]}") + + # Step 4: Attach batch datasets + print("Step 4: Attaching batch datasets...") + try: + batch_attach_result = self.client.initiate_attach_datasets_to_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_ids=test_dataset_ids, + ) + self.assertIsInstance(batch_attach_result, dict) + self.assertIn("response", batch_attach_result) + print(" Batch datasets attached successfully") + except LabellerrError as e: + # Handle "already attached" as a success case + error_str = str(e) + if "already been attached" in error_str or "already attached" in error_str: + print(" Batch datasets already attached (treating as success)") + else: + self.fail(f"Failed to attach batch datasets: {e}") + except Exception as e: + self.fail(f"Failed to attach batch datasets: {e}") + + print( + "\n Complete attach/detach workflow successful (single & batch operations)" + ) + + def test_attach_dataset_invalid_project_id(self): + """Test dataset attachment with invalid project_id format""" + with self.assertRaises(LabellerrError): + self.client.initiate_attach_dataset_to_project( + client_id=self.client_id, + project_id="invalid-project-id", + dataset_id=self.test_dataset_id, + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_attach_dataset_invalid_dataset_id(self): + """Test dataset attachment with invalid dataset_id format""" + with self.assertRaises(ValidationError) as context: + self.client.initiate_attach_dataset_to_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id="invalid-dataset-id", + ) + + # The error message should contain UUID validation error + error_msg = str(context.exception) + self.assertTrue( + "valid UUID" in error_msg + or "Invalid" in error_msg + or "uuid" in error_msg.lower() + ) + + def test_attach_dataset_missing_client_id(self): + """Test dataset attachment with missing client_id""" + with self.assertRaises(ValidationError) as context: + self.client.initiate_attach_dataset_to_project( + client_id="", + project_id=self.test_project_id, + dataset_id=self.test_dataset_id, + ) + + error_msg = str(context.exception) + self.assertTrue( + "at least 1 character" in error_msg or "Required parameter" in error_msg + ) + + def test_attach_dataset_nonexistent_project(self): + """Test dataset attachment with non-existent project_id""" + with self.assertRaises(LabellerrError): + self.client.initiate_attach_dataset_to_project( + client_id=self.client_id, + project_id="00000000-0000-0000-0000-000000000000", + dataset_id=self.test_dataset_id, + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_attach_dataset_nonexistent_dataset(self): + """Test dataset attachment with non-existent dataset_id""" + with self.assertRaises(LabellerrError): + self.client.initiate_attach_dataset_to_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id="00000000-0000-0000-0000-000000000000", + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_detach_dataset_invalid_project_id(self): + """Test dataset detachment with invalid project_id format""" + with self.assertRaises(LabellerrError): + self.client.initiate_detach_dataset_from_project( + client_id=self.client_id, + project_id="invalid-project-id", + dataset_id=self.test_dataset_id, + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_detach_dataset_invalid_dataset_id(self): + """Test dataset detachment with invalid dataset_id format""" + with self.assertRaises(ValidationError) as context: + self.client.initiate_detach_dataset_from_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id="invalid-dataset-id", + ) + + # The error message should contain UUID validation error + error_msg = str(context.exception) + self.assertTrue( + "valid UUID" in error_msg + or "Invalid" in error_msg + or "uuid" in error_msg.lower() + ) + + def test_detach_dataset_missing_client_id(self): + """Test dataset detachment with missing client_id""" + with self.assertRaises(ValidationError) as context: + self.client.initiate_detach_dataset_from_project( + client_id="", + project_id=self.test_project_id, + dataset_id=self.test_dataset_id, + ) + + error_msg = str(context.exception) + self.assertTrue( + "at least 1 character" in error_msg or "Required parameter" in error_msg + ) + + def test_detach_dataset_nonexistent_project(self): + """Test dataset detachment with non-existent project_id""" + with self.assertRaises(LabellerrError): + self.client.initiate_detach_dataset_from_project( + client_id=self.client_id, + project_id="00000000-0000-0000-0000-000000000000", + dataset_id=self.test_dataset_id, + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_detach_dataset_nonexistent_dataset(self): + """Test dataset detachment with non-existent dataset_id""" + with self.assertRaises(LabellerrError): + self.client.initiate_detach_dataset_from_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_id="00000000-0000-0000-0000-000000000000", + ) + # Just verify that an error is raised - the exact error message is API-dependent + + def test_attach_datasets_batch_invalid_dataset_id(self): + """Test batch attach with one invalid dataset_id format""" + # Mix of valid UUID and invalid string + test_dataset_ids = [self.test_dataset_id, "invalid-id"] + + with self.assertRaises(ValidationError) as context: + self.client.initiate_attach_datasets_to_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_ids=test_dataset_ids, + ) + + error_msg = str(context.exception) + self.assertTrue( + "valid UUID" in error_msg + or "Invalid" in error_msg + or "uuid" in error_msg.lower() + ) + + def test_detach_datasets_batch_invalid_dataset_id(self): + """Test batch detach with one invalid dataset_id format""" + # Mix of valid UUID and invalid string + test_dataset_ids = [self.test_dataset_id, "invalid-id"] + + with self.assertRaises(ValidationError) as context: + self.client.initiate_detach_datasets_from_project( + client_id=self.client_id, + project_id=self.test_project_id, + dataset_ids=test_dataset_ids, + ) + + error_msg = str(context.exception) + self.assertTrue( + "valid UUID" in error_msg + or "Invalid" in error_msg + or "uuid" in error_msg.lower() + ) + + def test_enable_multimodal_indexing(self): + """Test enabling multimodal indexing for a dataset""" + result = self.client.enable_multimodal_indexing( + client_id=self.client_id, + dataset_id=self.test_dataset_id, + is_multimodal=True, + ) + + self.assertIsInstance(result, dict) + self.assertIn("response", result) + print(" Multimodal indexing enabled successfully") + + def test_disable_multimodal_indexing(self): + """Test disabling multimodal indexing for a dataset""" + result = self.client.enable_multimodal_indexing( + client_id=self.client_id, + dataset_id=self.test_dataset_id, + is_multimodal=False, + ) + + self.assertIsInstance(result, dict) + self.assertIn("response", result) + print(" Multimodal indexing disabled successfully") + + def test_multimodal_indexing_invalid_dataset_id(self): + """Test multimodal indexing with invalid dataset_id format""" + with self.assertRaises(ValidationError) as context: + self.client.enable_multimodal_indexing( + client_id=self.client_id, + dataset_id="invalid-dataset-id", + is_multimodal=True, + ) + + self.assertIn("valid UUID", str(context.exception)) + + def test_multimodal_indexing_missing_client_id(self): + """Test multimodal indexing with missing client_id""" + with self.assertRaises(ValidationError) as context: + self.client.enable_multimodal_indexing( + client_id="", + dataset_id=self.test_dataset_id, + is_multimodal=True, + ) + + self.assertIn("at least 1 character", str(context.exception)) + + def test_multimodal_indexing_workflow_integration(self): + """Integration test for complete multimodal indexing workflow""" + try: + # Step 1: Enable multimodal indexing + print("Step 1: Enabling multimodal indexing...") + + enable_result = self.client.enable_multimodal_indexing( + client_id=self.client_id, + dataset_id=self.test_dataset_id, + is_multimodal=True, + ) + self.assertIsInstance(enable_result, dict) + self.assertIn("response", enable_result) + print("Multimodal indexing enabled successfully") + + # Step 2: Verify indexing status + print("Step 2: Verifying indexing status...") + + # Step 3: Disable multimodal indexing + print("Step 3: Disabling multimodal indexing...") + + disable_result = self.client.enable_multimodal_indexing( + client_id=self.client_id, + dataset_id=self.test_dataset_id, + is_multimodal=False, + ) + self.assertIsInstance(disable_result, dict) + self.assertIn("response", disable_result) + print(" Multimodal indexing disabled successfully") + + print(" Complete multimodal indexing workflow successful") + + except LabellerrError as e: + self.fail(f"Integration test failed with LabellerrError: {e}") + except Exception as e: + self.fail(f"Integration test failed with unexpected error: {e}") + + def test_get_multimodal_indexing_status(self): + """Test getting multimodal indexing status for a dataset""" + try: + status_result = self.client.get_multimodal_indexing_status( + client_id=self.client_id, + dataset_id=self.test_dataset_id, + ) + + self.assertIsInstance(status_result, dict) + self.assertIn("message", status_result) + self.assertIn("response", status_result) + + response_data = status_result["response"] + if response_data is not None: + self.assertIsInstance(response_data, dict) + self.assertIn("status", response_data) + + print("Get multimodal indexing status test passed") + + except LabellerrError as e: + self.fail( + f"Get multimodal indexing status test failed with LabellerrError: {e}" + ) + except Exception as e: + self.fail( + f"Get multimodal indexing status test failed with unexpected error: {e}" + ) + + def test_user_management_workflow(self): + """Test complete user management workflow: create, update, add to project, change role, remove, delete""" + try: + test_email = f"test_user_{int(time.time())}@example.com" + test_first_name = "Test" + test_last_name = "User" + test_user_id = f"test-user-{int(time.time())}" + test_project_id = "sunny_tough_blackbird_40468" + test_role_id = "7" + test_new_role_id = "5" + + # Step 1: Create a user + print(f"\n=== Step 1: Creating user {test_email} ===") + create_result = self.client.create_user( + client_id=self.client_id, + first_name=test_first_name, + last_name=test_last_name, + email_id=test_email, + projects=[test_project_id], + roles=[{"project_id": test_project_id, "role_id": test_role_id}], + ) + print(f"User creation result: {create_result}") + self.assertIsNotNone(create_result) + + # Step 2: Update user role + print(f"\n=== Step 2: Updating user role for {test_email} ===") + update_result = self.client.update_user_role( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + roles=[{"project_id": test_project_id, "role_id": test_new_role_id}], + first_name=test_first_name, + last_name=test_last_name, + ) + print(f"User role update result: {update_result}") + self.assertIsNotNone(update_result) + + # Step 3: Add user to project (if not already added) + # TODO: @ximi + # INFO:root:Checkout User - Status: 404, Message: NotFound: AltairOne user not found. + # 2025-10-09 21:40:48.976 IST + # INFO:root:NotFound: AltairOne user not found. + # print(f"\n=== Step 3: Adding user to project {test_project_id} ===") + # add_result = self.client.add_user_to_project( + # client_id=self.client_id, + # project_id=test_project_id, + # email_id=test_email, + # role_id=test_role_id, + # ) + # print(f"Add user to project result: {add_result}") + # self.assertIsNotNone(add_result) + + # Step 4: Change user role + print(f"\n=== Step 4: Changing user role for {test_email} ===") + change_role_result = self.client.change_user_role( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + new_role_id=test_new_role_id, + ) + print(f"Change user role result: {change_role_result}") + self.assertIsNotNone(change_role_result) + + # Step 5: Remove user from project + print(f"\n=== Step 5: Removing user from project {test_project_id} ===") + remove_result = self.client.remove_user_from_project( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + ) + print(f"Remove user from project result: {remove_result}") + self.assertIsNotNone(remove_result) + + # Step 6: Delete user + print(f"\n=== Step 6: Deleting user {test_email} ===") + delete_result = self.client.delete_user( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + user_id=test_user_id, + first_name=test_first_name, + last_name=test_last_name, + ) + print(f"Delete user result: {delete_result}") + self.assertIsNotNone(delete_result) + + print("Complete user management workflow completed successfully") + + except Exception as e: + print(f" User management workflow failed: {str(e)}") + raise + + def test_create_user_integration(self): + """Test user creation with real API calls""" + try: + test_email = f"integration_test_{int(time.time())}@example.com" + test_first_name = "Integration" + test_last_name = "Test" + test_project_id = "test_project_1233" + test_role_id = "7" + + print(f"\n=== Testing user creation for {test_email} ===") + + result = self.client.create_user( + client_id=self.client_id, + first_name=test_first_name, + last_name=test_last_name, + email_id=test_email, + projects=[test_project_id], + roles=[{"project_id": test_project_id, "role_id": test_role_id}], + work_phone="123-456-7890", + job_title="Test Engineer", + language="en", + timezone="GMT", + ) + + print(f"User creation result: {result}") + self.assertIsNotNone(result) + + try: + self.client.delete_user( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + user_id=f"test-user-{int(time.time())}", + first_name=test_first_name, + last_name=test_last_name, + ) + print(f"Cleanup: User {test_email} deleted successfully") + except Exception as cleanup_error: + print( + f"Cleanup warning: Could not delete user {test_email}: {cleanup_error}" + ) + + except Exception as e: + print(f" User creation integration test failed: {str(e)}") + raise + + def test_update_user_role_integration(self): + """Test user role update with real API calls""" + try: + test_email = f"update_test_{int(time.time())}@example.com" + test_first_name = "Update" + test_last_name = "Test" + test_project_id = "test_project_123" + test_role_id = "7" + test_new_role_id = "5" + + print(f"\n=== Testing user role update for {test_email} ===") + + create_result = self.client.create_user( + client_id=self.client_id, + first_name=test_first_name, + last_name=test_last_name, + email_id=test_email, + projects=[test_project_id], + roles=[{"project_id": test_project_id, "role_id": test_role_id}], + ) + print(f"User creation result: {create_result}") + + update_result = self.client.update_user_role( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + roles=[{"project_id": test_project_id, "role_id": test_new_role_id}], + first_name=test_first_name, + last_name=test_last_name, + work_phone="987-654-3210", + job_title="Senior Test Engineer", + language="en", + timezone="UTC", + ) + + print(f"User role update result: {update_result}") + self.assertIsNotNone(update_result) + + try: + self.client.delete_user( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + user_id=f"test-user-{int(time.time())}", + first_name=test_first_name, + last_name=test_last_name, + ) + print(f" Cleanup: User {test_email} deleted successfully") + except Exception as cleanup_error: + print( + f" Cleanup warning: Could not delete user {test_email}: {cleanup_error}" + ) + + except Exception as e: + print(f" User role update integration test failed: {str(e)}") + raise + + def test_project_user_management_integration(self): + """Test project user management operations with real API calls""" + try: + test_email = f"project_test_{int(time.time())}@example.com" + test_first_name = "Project" + test_last_name = "Test" + test_project_id = "test_project_123" + test_role_id = "7" + test_new_role_id = "5" + + print(f"\n=== Testing project user management for {test_email} ===") + + # Step 1: Create a user + create_result = self.client.create_user( + client_id=self.client_id, + first_name=test_first_name, + last_name=test_last_name, + email_id=test_email, + projects=[test_project_id], + roles=[{"project_id": test_project_id, "role_id": test_role_id}], + ) + print(f"User creation result: {create_result}") + self.assertIsNotNone(create_result) + + # Step 2: Update user role (use update_user_role instead of separate add/change operations) + update_result = self.client.update_user_role( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + roles=[{"project_id": test_project_id, "role_id": test_new_role_id}], + first_name=test_first_name, + last_name=test_last_name, + ) + print(f"Update user role result: {update_result}") + self.assertIsNotNone(update_result) + + try: + self.client.delete_user( + client_id=self.client_id, + project_id=test_project_id, + email_id=test_email, + user_id=f"test-user-{int(time.time())}", + first_name=test_first_name, + last_name=test_last_name, + ) + print(f" Cleanup: User {test_email} deleted successfully") + except Exception as cleanup_error: + print( + f" Cleanup warning: Could not delete user {test_email}: {cleanup_error}" + ) + + except Exception as e: + print(f" Project user management integration test failed: {str(e)}") + raise + + def test_user_management_error_handling(self): + """Test user management error handling with invalid inputs""" + try: + print("=== Testing user management error handling ===") + + # Test with invalid client_id + try: + self.client.create_user( + client_id="invalid_client_id", + first_name="Test", + last_name="User", + email_id="test@example.com", + projects=["project_123"], + roles=[{"project_id": "project_123", "role_id": "7"}], + ) + self.fail("Expected error for invalid client_id") + except Exception as e: + print(f" Correctly caught error for invalid client_id: {str(e)}") + + with self.assertRaises(ValidationError) as e: + self.client.create_user( + client_id=self.client_id, + first_name="Test", + last_name="", # Empty string - should fail validation + email_id="", # Empty string - should fail validation + projects=[], # Empty list - should fail validation + roles=[], # Empty list - should fail validation + ) + print( + f" Correctly caught ValidationError for empty parameters: {str(e.exception)}" + ) + + # Test with invalid email format + try: + self.client.create_user( + client_id=self.client_id, + first_name="Test", + last_name="User", + email_id="invalid_email", # Invalid email format + projects=["project_123"], + roles=[{"project_id": "project_123", "role_id": "7"}], + ) + print(" Note: Email validation may not be enforced at SDK level") + except Exception as e: + print(f" Correctly caught error for invalid email: {str(e)}") + + print(" User management error handling tests completed successfully!") + + except Exception as e: + print(f"User management error handling test failed: {str(e)}") + raise + + @classmethod + def setUpClass(cls): + """Set up test suite.""" + + def tearDown(self): + pass + + @classmethod + def tearDownClass(cls): + """Tear down test suite.""" + + def run_user_management_tests(self): + """Run only the user management integration tests""" + + # Check for required environment variables + required_env_vars = ["API_KEY", "API_SECRET", "CLIENT_ID", "TEST_EMAIL"] + missing_vars = [var for var in required_env_vars if not os.getenv(var)] + + if missing_vars: + print(f"Missing required environment variables: {', '.join(missing_vars)}") + print("Please set the following environment variables:") + for var in missing_vars: + print(f" export {var}=your_value") + return False + + print("🚀 Running User Management Integration Tests") + print("=" * 50) + + # Create test suite with only user management tests + suite = unittest.TestSuite() + + # Add user management test methods + user_management_tests = [ + "test_user_management_workflow", + "test_create_user_integration", + "test_update_user_role_integration", + "test_project_user_management_integration", + "test_user_management_error_handling", + ] + + for test_name in user_management_tests: + suite.addTest(LabelerIntegrationTests(test_name)) + + # Run tests with verbose output + runner = unittest.TextTestRunner(verbosity=2, stream=sys.stdout) + result = runner.run(suite) + + # Print summary + print("\n" + "=" * 50) + if result.wasSuccessful(): + print("All user management integration tests passed!") + else: + print("Some user management integration tests failed!") + print(f"Failures: {len(result.failures)}") + print(f"Errors: {len(result.errors)}") + + return result.wasSuccessful() + + +def run_use_case_tests(): + + suite = unittest.TestLoader().loadTestsFromTestCase(LabelerIntegrationTests) + + # Run tests with verbose output + runner = unittest.TextTestRunner(verbosity=2, stream=sys.stdout) + result = runner.run(suite) + + # Return success status + return result.wasSuccessful() + + +if __name__ == "__main__": + """ + Environment Variables Required: + - API_KEY: Your Labellerr API key + - API_SECRET: Your Labellerr API secret + - CLIENT_ID: Your Labellerr client ID + - TEST_EMAIL: Valid email address for testing + - TEST_PROJECT_ID: (Optional) Project ID for attach/detach tests (default: "sunny_tough_blackbird_40468") + - TEST_DATASET_ID: (Optional) Dataset ID for attach/detach tests (default: "055fecfe-d80e-4b93-90dd-dbb3a02dc03a") + - AWS_CONNECTION_VIDEO: AWS video connection id + - AWS_CONNECTION_IMAGE: AWS image connection id + - GCS_CONNECTION_VIDEO: JSON string with GCS video creds {"cred_file:"{}","gcs_path":"gs://bucket/path"} + - GCS_CONNECTION_IMAGE: JSON string with GCS image creds {"cred_file:"{}","gcs_path":"gs://bucket/path"} + + New User Management Tests Added: + - test_user_management_workflow: Complete user lifecycle test + - test_create_user_integration: User creation with real API calls + - test_update_user_role_integration: User role updates with real API calls + - test_project_user_management_integration: Project user management operations + - test_user_management_error_handling: Error handling validation + + New Batch Operation Tests Added: + - test_attach_datasets_batch_success: Test batch attachment of datasets + - test_attach_datasets_batch_invalid_dataset_id: Test batch attach with invalid IDs + - test_detach_datasets_batch_success: Test batch detachment of datasets + - test_detach_datasets_batch_invalid_dataset_id: Test batch detach with invalid IDs + + Run with: + python labellerr_integration_case_tests.py + """ + # Check for required environment variables + required_env_vars = [ + "API_KEY", + "API_SECRET", + "CLIENT_ID", + "TEST_EMAIL", + "AWS_CONNECTION_VIDEO", + "AWS_CONNECTION_IMAGE", + "GCS_CONNECTION_VIDEO", + "GCS_CONNECTION_IMAGE", + ] + + missing_vars = [var for var in required_env_vars if not os.getenv(var)] + + # Run the tests + success = run_use_case_tests() + + # Exit with appropriate code + sys.exit(0 if success else 1) diff --git a/tests/labellerr_keyframes_integration_case_tests.py b/tests/labellerr_keyframes_integration_case_tests.py new file mode 100644 index 0000000..9c96ad2 --- /dev/null +++ b/tests/labellerr_keyframes_integration_case_tests.py @@ -0,0 +1,481 @@ +import os + +import pytest + +from labellerr.client import KeyFrame, LabellerrClient +from labellerr.exceptions import LabellerrError + + +@pytest.fixture +def client(): + """Create a client for integration testing""" + api_key = os.environ.get("LABELLERR_API_KEY", "test_api_key") + api_secret = os.environ.get("LABELLERR_API_SECRET", "test_api_secret") + return LabellerrClient(api_key, api_secret) + + +class TestKeyFrameBusinessScenarios: + """Integration tests focused on business scenarios and workflows""" + + def test_video_annotation_workflow(self, client): + """ + Test complete workflow: Create keyframes for video annotation project + + Business scenario: + - Annotator is working on a video file + - They identify key moments at specific frames + - Some frames are manually selected, others are AI-suggested + - They need to link these keyframes to the video file + """ + # Business data: Video annotation project + client_id = "video_annotation_team" + project_id = "wildlife_documentary_2024" + video_file_id = "nature_scene_001.mp4" + + # Business scenario: Mixed manual and AI keyframes + keyframes = [ + # Start frame + KeyFrame( + frame_number=0, is_manual=True, method="manual", source="annotator" + ), + # AI detected movement + KeyFrame( + frame_number=150, + is_manual=False, + method="ai_detection", + source="cv_model", + ), + # Important scene change + KeyFrame( + frame_number=300, is_manual=True, method="manual", source="annotator" + ), + # AI detected object + KeyFrame( + frame_number=450, + is_manual=False, + method="ai_detection", + source="cv_model", + ), + # End of segment + KeyFrame( + frame_number=600, is_manual=True, method="manual", source="annotator" + ), + ] + + # Test the business operation + try: + result = client.link_key_frame( + client_id, project_id, video_file_id, keyframes + ) + # In real integration, we'd verify the result structure + # For now, we verify the method accepts business-realistic data + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment - API will reject with auth/project errors + # This validates our input format is correct for business scenarios + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + def test_security_surveillance_workflow(self, client): + """ + Test workflow: Security camera footage analysis + + Business scenario: + - Security team analyzing surveillance footage + - System auto-detects suspicious activity at certain frames + - Security operator manually reviews and marks additional frames + """ + client_id = "security_operations" + project_id = "building_surveillance_q4" + footage_file_id = "camera_03_20241215_1400.mp4" + + # Business scenario: Security incident keyframes + incident_keyframes = [ + # Review start + KeyFrame( + frame_number=0, is_manual=True, method="manual", source="operator" + ), + # Auto-detected motion + KeyFrame( + frame_number=2340, + is_manual=False, + method="motion_detection", + source="ai", + ), + # Operator verification + KeyFrame( + frame_number=2380, is_manual=True, method="manual", source="operator" + ), + # Face detected + KeyFrame( + frame_number=2420, is_manual=False, method="face_detection", source="ai" + ), + # Incident end + KeyFrame( + frame_number=2500, is_manual=True, method="manual", source="operator" + ), + ] + + try: + result = client.link_key_frame( + client_id, project_id, footage_file_id, incident_keyframes + ) + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + def test_quality_control_workflow(self, client): + """ + Test workflow: Quality control in manufacturing + + Business scenario: + - Quality inspector reviewing production line video + - Identifying frames where defects occur + - Marking frames for further analysis + """ + client_id = "quality_control_dept" + project_id = "production_line_inspection" + video_file_id = "assembly_station_5.mp4" + + # Business scenario: Defect detection keyframes + qc_keyframes = [ + # Inspection start + KeyFrame( + frame_number=100, is_manual=True, method="manual", source="inspector" + ), + # Potential defect spotted + KeyFrame( + frame_number=500, is_manual=True, method="manual", source="inspector" + ), + # AI flagged anomaly + KeyFrame( + frame_number=1200, + is_manual=False, + method="anomaly_detection", + source="ai", + ), + # Confirmed defect + KeyFrame( + frame_number=1800, is_manual=True, method="manual", source="inspector" + ), + ] + + try: + result = client.link_key_frame( + client_id, project_id, video_file_id, qc_keyframes + ) + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + def test_content_moderation_workflow(self, client): + """ + Test workflow: Content moderation for social media + + Business scenario: + - Content moderator reviewing user-uploaded videos + - Flagging inappropriate content at specific timestamps + - Marking frames for review or removal + """ + client_id = "content_moderation" + project_id = "user_content_review_dec2024" + user_video_id = "user_upload_xyz789.mp4" + + # Business scenario: Content moderation keyframes + moderation_keyframes = [ + KeyFrame( + frame_number=0, is_manual=True, method="manual", source="moderator" + ), # Review start + KeyFrame( + frame_number=750, is_manual=False, method="content_filter", source="ai" + ), # AI flagged content + KeyFrame( + frame_number=1500, is_manual=True, method="manual", source="moderator" + ), # Manual review + KeyFrame( + frame_number=2200, is_manual=True, method="manual", source="moderator" + ), # Final decision + ] + + try: + result = client.link_key_frame( + client_id, project_id, user_video_id, moderation_keyframes + ) + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + def test_keyframe_cleanup_workflow(self, client): + """ + Test workflow: Project cleanup after annotation completion + + Business scenario: + - Project manager cleaning up completed annotation projects + - Removing temporary keyframes that are no longer needed + - Preparing for project archival + """ + client_id = "project_management" + completed_project_id = "medical_imaging_batch_03" + + try: + result = client.delete_key_frames(client_id, completed_project_id) + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + def test_batch_processing_workflow(self, client): + """ + Test workflow: Batch processing multiple video segments + + Business scenario: + - Data scientist processing multiple video files + - Each file gets the same keyframe pattern for consistency + - Batch operation for efficiency + """ + client_id = "data_science_team" + project_id = "sports_analysis_dataset" + + # Business scenario: Standardized keyframes for multiple files + standard_keyframes = [ + KeyFrame( + frame_number=0, + is_manual=False, + method="automatic", + source="batch_processor", + ), # Start + KeyFrame( + frame_number=600, + is_manual=False, + method="automatic", + source="batch_processor", + ), # Mid-point + KeyFrame( + frame_number=1200, + is_manual=False, + method="automatic", + source="batch_processor", + ), # End + ] + + # Simulate batch processing multiple files + video_files = [ + "game1_highlight_reel.mp4", + "game2_highlight_reel.mp4", + "game3_highlight_reel.mp4", + ] + + for video_file in video_files: + try: + result = client.link_key_frame( + client_id, project_id, video_file, standard_keyframes + ) + assert isinstance(result, dict) + except LabellerrError as e: + # Expected in test environment + error_str = str(e).lower() + assert any( + word in error_str + for word in [ + "not authorized", + "invalid api", + "test_api_key", + "project", + "client", + ] + ) + + +class TestKeyFrameDataValidation: + """Integration tests focused on data validation in business contexts""" + + def test_realistic_keyframe_data_types(self, client): + """Test that business-realistic keyframe data is properly validated""" + + # Valid business scenarios + valid_scenarios = [ + # Medical imaging keyframes + KeyFrame( + frame_number=1, + is_manual=True, + method="radiologist_review", + source="doctor", + ), + # Sports analysis keyframes + KeyFrame( + frame_number=1800, + is_manual=False, + method="player_tracking", + source="sports_ai", + ), + # Education content keyframes + KeyFrame( + frame_number=300, + is_manual=True, + method="curriculum_design", + source="educator", + ), + # Research data keyframes + KeyFrame( + frame_number=10000, + is_manual=False, + method="pattern_recognition", + source="research_ai", + ), + ] + + for keyframe in valid_scenarios: + # Test that keyframes are created successfully + assert keyframe.frame_number >= 0 + assert isinstance(keyframe.is_manual, bool) + assert isinstance(keyframe.method, str) + assert isinstance(keyframe.source, str) + + def test_business_constraint_validation(self, client): + """Test business constraints are properly enforced""" + + # Test frame number constraints (must be non-negative integers) + with pytest.raises(ValueError): + KeyFrame( + frame_number=-1 + ) # Negative frame numbers don't make business sense + + # Test that all required business fields are validated + with pytest.raises(ValueError): + KeyFrame(frame_number="not_a_number") # Frame numbers must be integers + + def test_workflow_integration_patterns(self, client): + """Test common integration patterns in business workflows""" + + # Pattern 1: Progressive annotation workflow + progressive_keyframes = [] + for frame_num in range(0, 1000, 100): # Every 100 frames + kf = KeyFrame( + frame_number=frame_num, + is_manual=frame_num % 200 == 0, # Every other keyframe is manual + method="progressive_annotation", + source="workflow_engine", + ) + progressive_keyframes.append(kf) + + assert len(progressive_keyframes) == 10 + assert all(isinstance(kf, KeyFrame) for kf in progressive_keyframes) + + # Pattern 2: Mixed manual/automatic workflow + mixed_keyframes = [ + KeyFrame( + frame_number=0, is_manual=True, method="manual_start", source="user" + ), + KeyFrame( + frame_number=500, is_manual=False, method="ai_suggestion", source="ai" + ), + KeyFrame( + frame_number=1000, + is_manual=True, + method="manual_verification", + source="user", + ), + KeyFrame( + frame_number=1500, is_manual=False, method="ai_suggestion", source="ai" + ), + KeyFrame( + frame_number=2000, is_manual=True, method="manual_end", source="user" + ), + ] + + # Verify workflow makes business sense + manual_count = sum(1 for kf in mixed_keyframes if kf.is_manual) + auto_count = sum(1 for kf in mixed_keyframes if not kf.is_manual) + assert manual_count == 3 # Human oversight points + assert auto_count == 2 # AI assistance points + + +class TestErrorScenarios: + """Integration tests for realistic error scenarios""" + + def test_authentication_error_scenario(self, client): + """Test realistic authentication failure scenario""" + # Business scenario: Team member's API key has expired + client_id = "expired_team_member" + project_id = "active_project" + file_id = "important_video.mp4" + keyframes = [KeyFrame(frame_number=100)] + + try: + client.link_key_frame(client_id, project_id, file_id, keyframes) + except LabellerrError as e: + # This is expected in test environment with fake credentials + assert isinstance(e, LabellerrError) + + def test_project_not_found_scenario(self, client): + """Test realistic project not found scenario""" + # Business scenario: Team member tries to access archived project + client_id = "valid_team_member" + archived_project_id = "archived_project_2023" + + try: + client.delete_key_frames(client_id, archived_project_id) + except LabellerrError as e: + # This is expected in test environment + assert isinstance(e, LabellerrError) + + def test_invalid_business_data_scenario(self): + """Test invalid business data scenarios""" + # Business scenario: Invalid frame numbers from corrupted data + with pytest.raises(ValueError): + KeyFrame(frame_number=None) # Corrupted data + + with pytest.raises(ValueError): + KeyFrame(frame_number="corrupted") # Bad data import diff --git a/tests/test_client.py b/tests/test_client.py index a20543e..e02c21d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -423,5 +423,319 @@ def test_bulk_assign_files_missing_required(self, client): ) +class TestBulkAssignFiles: + """Comprehensive tests for bulk_assign_files method""" + + def test_bulk_assign_files_invalid_client_id_type(self, client): + """Test error handling for invalid client_id type""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id=12345, # Not a string + project_id="project_123", + file_ids=["file1", "file2"], + new_status="completed", + ) + assert "client_id" in str(exc_info.value).lower() + + def test_bulk_assign_files_empty_client_id(self, client): + """Test error handling for empty client_id""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="", + project_id="project_123", + file_ids=["file1", "file2"], + new_status="completed", + ) + assert "client_id" in str(exc_info.value).lower() + + def test_bulk_assign_files_invalid_project_id_type(self, client): + """Test error handling for invalid project_id type""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id=12345, # Not a string + file_ids=["file1", "file2"], + new_status="completed", + ) + assert "project_id" in str(exc_info.value).lower() + + def test_bulk_assign_files_empty_project_id(self, client): + """Test error handling for empty project_id""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="", + file_ids=["file1", "file2"], + new_status="completed", + ) + assert "project_id" in str(exc_info.value).lower() + + def test_bulk_assign_files_empty_file_ids_list(self, client): + """Test error handling for empty file_ids list""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=[], # Empty list + new_status="completed", + ) + assert "file_ids" in str(exc_info.value).lower() + + def test_bulk_assign_files_invalid_file_ids_type(self, client): + """Test error handling for invalid file_ids type""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids="file1,file2", # Not a list + new_status="completed", + ) + assert "file_ids" in str(exc_info.value).lower() + + def test_bulk_assign_files_file_ids_with_non_string(self, client): + """Test error handling for file_ids containing non-string values""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=["file1", 123, "file3"], # Contains integer + new_status="completed", + ) + assert "file_ids" in str(exc_info.value).lower() + + def test_bulk_assign_files_invalid_new_status_type(self, client): + """Test error handling for invalid new_status type""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=["file1", "file2"], + new_status=123, # Not a string + ) + assert "new_status" in str(exc_info.value).lower() + + def test_bulk_assign_files_empty_new_status(self, client): + """Test error handling for empty new_status""" + with pytest.raises(ValidationError) as exc_info: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=["file1", "file2"], + new_status="", + ) + assert "new_status" in str(exc_info.value).lower() + + def test_bulk_assign_files_single_file(self, client): + """Test bulk assign with a single file""" + # This should not raise validation errors + try: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=["file1"], + new_status="completed", + ) + except ValidationError: + pytest.fail("Validation should pass for single file") + except Exception: + # API call will fail but validation should pass + pass + + def test_bulk_assign_files_multiple_files(self, client): + """Test bulk assign with multiple files""" + # This should not raise validation errors + try: + client.bulk_assign_files( + client_id="12345", + project_id="project_123", + file_ids=["file1", "file2", "file3", "file4", "file5"], + new_status="in_progress", + ) + except ValidationError: + pytest.fail("Validation should pass for multiple files") + except Exception: + # API call will fail but validation should pass + pass + + def test_bulk_assign_files_special_characters_in_ids(self, client): + """Test bulk assign with special characters in IDs""" + try: + client.bulk_assign_files( + client_id="client-123_test", + project_id="project-456_test", + file_ids=["file-1_test", "file-2_test"], + new_status="pending", + ) + except ValidationError: + pytest.fail("Validation should pass for IDs with special characters") + except Exception: + # API call will fail but validation should pass + pass + + +class TestListFile: + """Comprehensive tests for list_file method""" + + def test_list_file_invalid_client_id_type(self, client): + """Test error handling for invalid client_id type""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id=12345, # Not a string + project_id="project_123", + search_queries={"status": "completed"}, + ) + assert "client_id" in str(exc_info.value).lower() + + def test_list_file_empty_client_id(self, client): + """Test error handling for empty client_id""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="", + project_id="project_123", + search_queries={"status": "completed"}, + ) + assert "client_id" in str(exc_info.value).lower() + + def test_list_file_invalid_project_id_type(self, client): + """Test error handling for invalid project_id type""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id=12345, # Not a string + search_queries={"status": "completed"}, + ) + assert "project_id" in str(exc_info.value).lower() + + def test_list_file_empty_project_id(self, client): + """Test error handling for empty project_id""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id="", + search_queries={"status": "completed"}, + ) + assert "project_id" in str(exc_info.value).lower() + + def test_list_file_invalid_search_queries_type(self, client): + """Test error handling for invalid search_queries type""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries="status:completed", # Not a dict + ) + assert "search_queries" in str(exc_info.value).lower() + + def test_list_file_invalid_size_type(self, client): + """Test error handling for invalid size type""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + size="invalid", # Non-numeric string + ) + assert "size" in str(exc_info.value).lower() + + def test_list_file_negative_size(self, client): + """Test error handling for negative size""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + size=-1, + ) + assert "size" in str(exc_info.value).lower() + + def test_list_file_zero_size(self, client): + """Test error handling for zero size""" + with pytest.raises(ValidationError) as exc_info: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + size=0, + ) + assert "size" in str(exc_info.value).lower() + + def test_list_file_with_default_size(self, client): + """Test list_file with default size parameter""" + try: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + ) + except ValidationError: + pytest.fail("Validation should pass with default size") + except Exception: + # API call will fail but validation should pass + pass + + def test_list_file_with_custom_size(self, client): + """Test list_file with custom size parameter""" + try: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + size=50, + ) + except ValidationError: + pytest.fail("Validation should pass with custom size") + except Exception: + # API call will fail but validation should pass + pass + + def test_list_file_with_next_search_after(self, client): + """Test list_file with next_search_after for pagination""" + try: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={"status": "completed"}, + size=10, + next_search_after="some_cursor_value", + ) + except ValidationError: + pytest.fail("Validation should pass with next_search_after") + except Exception: + # API call will fail but validation should pass + pass + + def test_list_file_complex_search_queries(self, client): + """Test list_file with complex search queries""" + try: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={ + "status": "completed", + "created_at": {"gte": "2024-01-01"}, + "tags": ["tag1", "tag2"], + }, + ) + except ValidationError: + pytest.fail("Validation should pass with complex search queries") + except Exception: + # API call will fail but validation should pass + pass + + def test_list_file_empty_search_queries(self, client): + """Test list_file with empty search queries dict""" + try: + client.list_file( + client_id="12345", + project_id="project_123", + search_queries={}, # Empty dict + ) + except ValidationError: + pytest.fail("Validation should pass with empty search queries") + except Exception: + # API call will fail but validation should pass + pass + + if __name__ == "__main__": pytest.main()