diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 33a4eba..3d50c75 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -7,4 +7,4 @@ message = [RELEASE] Bump version: {current_version} → {new_version} [bumpversion:file:pyproject.toml] search = version = "{current_version}" -replace = version = "{new_version}" \ No newline at end of file +replace = version = "{new_version}" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..82d6c7f --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Labellerr SDK Integration Test Environment Variables +# Copy this file to .env and update with your actual credentials + +# API Credentials +API_KEY=your_api_key_here +API_SECRET=your_api_secret_here +CLIENT_ID=your_client_id_here + +# Test Email +CLIENT_EMAIL=your_test_email@example.com + +# AWS Connection Credentials (JSON format) +# For video data type +AWS_CONNECTION_VIDEO={"access_key": "your_aws_access_key", "secret_key": "your_aws_secret_key", "s3_path": "your_s3_bucket_path", "data_type": "video", "name": "Video Connection", "description": "AWS S3 connection for video data"} + +# For image data type +AWS_CONNECTION_IMAGE={"access_key": "your_aws_access_key", "secret_key": "your_aws_secret_key", "s3_path": "your_s3_bucket_path", "data_type": "image", "name": "Image Connection", "description": "AWS S3 connection for image data"} + +# Optional: Additional test configuration +# TEST_TIMEOUT=300 +# DEBUG_MODE=false diff --git a/.flake8 b/.flake8 index 1405387..59d2386 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 160 extend-ignore = E203, W503, E402 -exclude = .git,__pycache__,.venv,build,dist,venv \ No newline at end of file +exclude = .git,__pycache__,.venv,build,dist,venv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 618d144..9c9ec81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI Pipeline +name: CI on: push: @@ -6,66 +6,54 @@ on: pull_request: branches: [ main, develop ] -env: - PYTHON_VERSION: '3.9' - jobs: test: - name: Test Suite runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.9'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Run linting - run: | - make lint - - name: Run linting - run: | - make format - - name: Run tests - run: | - make test - integration-test: - name: Integration Tests - runs-on: ubuntu-latest - needs: test - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' - + env: + API_KEY: ${{ secrets.API_KEY }} + API_SECRET: ${{ secrets.API_SECRET }} + CLIENT_ID: ${{ secrets.CLIENT_ID }} + TEST_EMAIL: ${{ secrets.TEST_EMAIL }} + AWS_CONNECTION_IMAGE: ${{ secrets.AWS_CONNECTION_IMAGE }} + AWS_CONNECTION_VIDEO: ${{ secrets.AWS_CONNECTION_VIDEO }} + GCS_CONNECTION_IMAGE: ${{ secrets.GCS_CONNECTION_IMAGE }} + GCS_CONNECTION_VIDEO: ${{ secrets.GCS_CONNECTION_VIDEO }} + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: '3.9' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Run integration tests - env: - LABELLERR_API_KEY: ${{ secrets.LABELLERR_API_KEY }} - LABELLERR_API_SECRET: ${{ secrets.LABELLERR_API_SECRET }} - LABELLERR_CLIENT_ID: ${{ secrets.LABELLERR_CLIENT_ID }} - LABELLERR_TEST_EMAIL: ${{ secrets.LABELLERR_TEST_EMAIL }} - run: | - python -m pytest labellerr_use_case_tests.py -v \ No newline at end of file + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run linting + run: | + if grep -q "^lint:" Makefile; then + make lint + else + pip install ruff + ruff check . + fi + + - name: Run formatting check + run: | + if grep -q "^format:" Makefile; then + make format + else + pip install black + black --check . + fi + + - name: Run unit tests + run: make test + + - name: Run integration tests + run: make integration-test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed4549b..22f41e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,8 +88,12 @@ jobs: LABELLERR_API_SECRET: ${{ secrets.LABELLERR_API_SECRET }} LABELLERR_CLIENT_ID: ${{ secrets.LABELLERR_CLIENT_ID }} LABELLERR_TEST_EMAIL: ${{ secrets.LABELLERR_TEST_EMAIL }} + AWS_CONNECTION_IMAGE: ${{ secrets.AWS_CONNECTION_IMAGE }} + AWS_CONNECTION_VIDEO: ${{ secrets.AWS_CONNECTION_VIDEO }} + GCS_CONNECTION_IMAGE: ${{ secrets.GCS_CONNECTION_IMAGE }} + GCS_CONNECTION_VIDEO: ${{ secrets.GCS_CONNECTION_VIDEO }} run: | - python -m pytest labellerr_use_case_tests.py -v + python -m pytest labellerr_integration_case_tests.py -v release: name: Create Release @@ -256,4 +260,4 @@ jobs: echo "❌ Release failed!" echo "Release job: ${{ needs.release.result }}" echo "Build job: ${{ needs.build.result }}" - exit 1 \ No newline at end of file + exit 1 diff --git a/.gitignore b/.gitignore index 74b2fb6..9cdce73 100644 --- a/.gitignore +++ b/.gitignore @@ -19,53 +19,10 @@ sdist/ var/ wheels/ *.egg-info/ -.installed.cfg -*.egg - -# IDE +*.pyc +*/*.pyc .idea -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Environment .env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Testing -.coverage -htmlcov/ -.pytest_cache/ -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.tox/ - -# Documentation -docs/_build/ -site/ - -# Release files -.bumpversion.cfg.bak -*.bak - -# Claude +.DS_Store .claude - -# Test data -tests/test_data +tests/test_data \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cac0187 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-merge-conflict + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: debug-statements + - id: mixed-line-ending + + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + args: ["--line-length=88"] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ["--profile=black", "--line-length=88"] + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + additional_dependencies: [] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + args: ["--config=pyproject.toml"] + additional_dependencies: + - types-requests + - types-aiofiles + files: ^labellerr/ + +ci: + autoupdate_schedule: quarterly + skip: [] diff --git a/Makefile b/Makefile index 5053e2a..629e295 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ format: build: ## Build package $(PYTHON) -m build -version: +version: @grep '^version = ' pyproject.toml | cut -d'"' -f2 | sed 's/^/Current version: /' || echo "Version not found" info: @@ -54,4 +54,11 @@ check-release: ## Check if everything is ready for release @echo "1. Create feature branch: git checkout -b feature/LABIMP-XXXX-release-vX.X.X" @echo "2. Update version in pyproject.toml" @echo "3. Commit: git commit -m '[LABIMP-XXXX] Prepare release vX.X.X'" - @echo "4. Push and create PR to main (patch) or develop (minor)" \ No newline at end of file + @echo "4. Push and create PR to main (patch) or develop (minor)" + +integration-test: + $(PYTHON) -m pytest -v labellerr_integration_case_tests.py + +pre-commit-install: + pip install pre-commit + pre-commit install diff --git a/README.md b/README.md index ad9553b..6f6f78d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ 8. [Retrieving All Datasets](#retrieving-all-datasets) - [Example Usage](#example-usage-3) 9. [Error Handling](#error-handling) -10. [Support](#support) +10. [Automatic Logging and Error Handling](#automatic-logging-and-error-handling) +11. [Support](#support) --- @@ -145,7 +146,7 @@ try: result = client.initiate_create_project(project_payload) print(f"Project created successfully. Project ID: {result['project_id']}") except LabellerrError as e: - print(f"Project creation failed: {str(e)}") + print(f"Project creation failed: {e}") ``` --- @@ -172,7 +173,7 @@ annotation_file = '/path/to/annotations.json' try: # Upload and wait for processing to complete result = client.upload_preannotation_by_project_id(project_id, client_id, annotation_format, annotation_file) - + # Check the final status if result['response']['status'] == 'completed': print("Pre-annotations processed successfully") @@ -180,7 +181,7 @@ try: metadata = result['response'].get('metadata', {}) print("metadata",metadata) except LabellerrError as e: - print(f"Pre-annotation upload failed: {str(e)}") + print(f"Pre-annotation upload failed: {e}") ``` #### Example Usage (Asynchronous): @@ -202,9 +203,9 @@ annotation_file = '/path/to/annotations.json' try: # Start the async upload - returns immediately future = client.upload_preannotation_by_project_id_async(project_id, client_id, annotation_format, annotation_file) - + print("Upload started, you can do other work here...") - + # When you need the result, wait for completion try: result = future.result(timeout=300) # 5 minutes timeout @@ -215,9 +216,9 @@ try: except TimeoutError: print("Processing took too long") except Exception as e: - print(f"Error in processing: {str(e)}") + print(f"Error in processing: {e}") except LabellerrError as e: - print(f"Failed to start upload: {str(e)}") + print(f"Failed to start upload: {e}") ``` #### Choosing Between Sync and Async @@ -283,7 +284,7 @@ try: result = client.create_local_export(project_id, client_id, export_config) print(f"Local export created successfully. Export ID: {result['export_id']}") except LabellerrError as e: - print(f"Local export creation failed: {str(e)}") + print(f"Local export creation failed: {e}") ``` **Note**: The export process creates a local copy of your project's annotations based on the specified status filters. This is useful for backup purposes or when you need to process the annotations offline. @@ -310,7 +311,7 @@ client_id = '12345' try: result = client.get_all_project_per_client_id(client_id) - + # Check if projects were retrieved successfully if result and 'response' in result: projects = result['response'] @@ -320,7 +321,7 @@ try: print(f" Name: {project.get('project_name')}") print(f" Type: {project.get('data_type')}") except LabellerrError as e: - print(f"Failed to retrieve projects: {str(e)}") + print(f"Failed to retrieve projects: {e}") ``` This method is useful when you need to: @@ -346,7 +347,6 @@ You can retrieve both linked and unlinked datasets associated with a client usin from labellerr.client import LabellerrClient from labellerr.exceptions import LabellerrError - # Initialize the client with your API credentials client = LabellerrClient('your_api_key', 'your_api_secret') @@ -354,8 +354,8 @@ client_id = '12345' data_type = 'image' try: - result = client.get_all_dataset(client_id, data_type) - + result = client.get_all_datasets(client_id, data_type) + # Process linked datasets linked_datasets = result['linked'] print(f"Found {len(linked_datasets)} linked datasets:") @@ -373,7 +373,7 @@ try: print(f" Description: {dataset.get('dataset_description')}") except LabellerrError as e: - print(f"Failed to retrieve datasets: {str(e)}") + print(f"Failed to retrieve datasets: {e}") ``` This method is useful when you need to: @@ -402,10 +402,100 @@ The Labellerr SDK uses a custom exception class, `LabellerrError`, to indicate i from labellerr.exceptions import LabellerrError try: - # Example function call result = client.initiate_create_project(payload) except LabellerrError as e: - print(f"An error occurred: {str(e)}") + print(f"An error occurred: {e}") +``` + +--- + +## Automatic Logging and Error Handling + +The Labellerr SDK uses **class-level decorators** to automatically apply logging and error handling to all public methods in both `LabellerrClient` and `AsyncLabellerrClient`. This means every method call is automatically: + +1. **Logged** when the method is called +2. **Logged** when the method completes successfully +3. **Logged** with error details if the method fails +4. **Wrapped** with standardized error handling + +### 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 + +### How It Works + +The SDK uses two decorators: +- `@auto_log_and_handle_errors` for synchronous methods +- `@auto_log_and_handle_errors_async` for asynchronous methods + +These decorators are applied at the class level, so all public methods (methods not starting with `_`) automatically inherit them. + +### Example Log Output + +When you call a method, you'll see debug logs like: + +``` +DEBUG - Calling create_gcs_connection +DEBUG - create_gcs_connection completed successfully +``` + +Or if an error occurs: + +``` +DEBUG - Calling create_gcs_connection +ERROR - create_gcs_connection failed: Connection refused +``` + +### Enabling Debug Logging + +To see the automatic logging in action, configure Python's logging: + +```python +import logging + +# Enable debug logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +from labellerr.client import LabellerrClient + +client = LabellerrClient('your_api_key', 'your_api_secret') + +# Now all method calls will be automatically logged +client.create_dataset(dataset_config, files_to_upload=['file1.jpg']) +``` + +### Excluded Methods + +Some methods are excluded from automatic decoration: +- Private methods (starting with `_`) +- Utility methods like `close()`, `validate_rotation_config()` +- Session management methods + +### Custom Implementation + +If you're building your own client or extending the SDK, you can use the same decorators: + +```python +from labellerr.validators import auto_log_and_handle_errors + +@auto_log_and_handle_errors( + include_params=False, # Don't log sensitive parameters + exclude_methods=['close', 'cleanup'] # Skip these methods +) +class MyCustomClient: + def my_method(self): + # This method automatically gets logging and error handling + pass + + def close(self): + # This method is excluded from decoration + pass ``` --- diff --git a/labellerr/__init__.py b/labellerr/__init__.py index fdc2454..1732c67 100644 --- a/labellerr/__init__.py +++ b/labellerr/__init__.py @@ -6,14 +6,12 @@ # Get version from package metadata try: - from importlib.metadata import version + import importlib.metadata as _importlib_metadata +except ImportError: # Python < 3.8 + import importlib_metadata as _importlib_metadata # type: ignore[no-redef] - __version__ = version("labellerr-sdk") -except ImportError: - # Python < 3.8 - from importlib_metadata import version - - __version__ = version("labellerr-sdk") +try: + __version__ = _importlib_metadata.version("labellerr-sdk") except Exception: __version__ = "unknown" diff --git a/labellerr/__pycache__/__init__.cpython-310.pyc b/labellerr/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 4a735b8..0000000 Binary files a/labellerr/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/labellerr/__pycache__/client.cpython-310.pyc b/labellerr/__pycache__/client.cpython-310.pyc deleted file mode 100644 index b20465a..0000000 Binary files a/labellerr/__pycache__/client.cpython-310.pyc and /dev/null differ diff --git a/labellerr/__pycache__/exceptions.cpython-310.pyc b/labellerr/__pycache__/exceptions.cpython-310.pyc deleted file mode 100644 index d984744..0000000 Binary files a/labellerr/__pycache__/exceptions.cpython-310.pyc and /dev/null differ diff --git a/labellerr/async_client.py b/labellerr/async_client.py index 01526dc..84efd97 100644 --- a/labellerr/async_client.py +++ b/labellerr/async_client.py @@ -4,15 +4,20 @@ import logging import os import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union import aiofiles import aiohttp -from . import constants, client_utils +from . import client_utils, constants from .exceptions import LabellerrError +from .validators import auto_log_and_handle_errors_async +@auto_log_and_handle_errors_async( + include_params=False, + exclude_methods=["close", "_ensure_session", "_build_headers"], +) class AsyncLabellerrClient: """ Async client for interacting with the Labellerr API using aiohttp for better performance. @@ -82,11 +87,68 @@ def _build_headers( extra_headers=extra_headers, ) + async def _request( + self, + method: str, + url: str, + request_id: Optional[str] = None, + success_codes: Optional[list] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + Make HTTP request and handle response in a single async method. + + :param method: HTTP method (GET, POST, etc.) + :param url: Request URL + :param request_id: Optional request tracking ID (auto-generated if not provided) + :param success_codes: Optional list of success status codes (default: [200, 201]) + :param kwargs: Additional arguments to pass to aiohttp + :return: JSON response data for successful requests + :raises LabellerrError: For non-successful responses + """ + # Generate request_id if not provided + if request_id is None: + request_id = str(uuid.uuid4()) + + await self._ensure_session() + + if success_codes is None: + success_codes = [200, 201] + + assert ( + self._session is not None + ), "Session must be initialized before making requests" + async with self._session.request(method, url, **kwargs) as response: + if response.status in success_codes: + try: + return await response.json() + except Exception: + text = await response.text() + raise LabellerrError(f"Expected JSON response but got: {text}") + elif 400 <= response.status < 500: + try: + error_data = await response.json() + raise LabellerrError({"error": error_data, "code": response.status}) + except Exception: + text = await response.text() + raise LabellerrError({"error": text, "code": response.status}) + else: + text = await response.text() + raise LabellerrError( + { + "status": "internal server error", + "message": "Please contact support with the request tracking id", + "request_id": request_id, + "error": text, + } + ) + async def _handle_response( self, response: aiohttp.ClientResponse, request_id: Optional[str] = None ) -> Dict[str, Any]: """ - Async standardized response handling. + Legacy method for handling response objects directly. + Kept for backward compatibility with special response handlers. :param response: aiohttp ClientResponse object :param request_id: Optional request tracking ID @@ -123,18 +185,15 @@ async def get_direct_upload_url( """ Async version of get_direct_upload_url. """ - await self._ensure_session() - url = f"{constants.BASE_URL}/connectors/direct-upload-url" params = {"client_id": client_id, "purpose": purpose, "file_name": file_name} headers = self._build_headers(client_id=client_id) try: - async with self._session.get( - url, params=params, headers=headers - ) as response: - response_data = await self._handle_response(response) - return response_data["response"] + response_data = await self._request( + "GET", url, params=params, headers=headers + ) + return response_data["response"] except Exception as e: logging.exception(f"Error getting direct upload url: {e}") raise @@ -145,20 +204,17 @@ async def connect_local_files( """ Async version of connect_local_files. """ - await self._ensure_session() - url = f"{constants.BASE_URL}/connectors/connect/local" params = {"client_id": client_id} headers = self._build_headers(client_id=client_id) - body = {"file_names": file_names} + body: Dict[str, Any] = {"file_names": file_names} if connection_id is not None: body["temporary_connection_id"] = connection_id - async with self._session.post( - url, params=params, headers=headers, json=body - ) as response: - return await self._handle_response(response) + return await self._request( + "POST", url, params=params, headers=headers, json=body + ) async def upload_file_stream( self, signed_url: str, file_path: str, chunk_size: int = 8192 @@ -180,6 +236,9 @@ async def upload_file_stream( } async with aiofiles.open(file_path, "rb") as f: + assert ( + self._session is not None + ), "Session must be initialized before uploading files" async with self._session.put( signed_url, headers=headers, data=f ) as response: @@ -189,7 +248,7 @@ async def upload_file_stream( return True async def upload_files_batch( - self, client_id: str, files_list: List[str], batch_size: int = 5 + self, client_id: str, files_list: Union[List[str], str], batch_size: int = 5 ) -> str: """ Async batch file upload with concurrency control. @@ -199,25 +258,28 @@ async def upload_files_batch( :param batch_size: Number of concurrent uploads :return: Connection ID """ + normalized_files_list: List[str] if isinstance(files_list, str): - files_list = files_list.split(",") - elif not isinstance(files_list, list): + normalized_files_list = files_list.split(",") + elif isinstance(files_list, list): + normalized_files_list = files_list + else: raise LabellerrError( "files_list must be either a list or a comma-separated string" ) - if len(files_list) == 0: + if len(normalized_files_list) == 0: raise LabellerrError("No files to upload") # Validate files exist - for file_path in files_list: + for file_path in normalized_files_list: if not os.path.exists(file_path): raise LabellerrError(f"File does not exist: {file_path}") if not os.path.isfile(file_path): raise LabellerrError(f"Path is not a file: {file_path}") # Get upload URLs and connection ID - file_names = [os.path.basename(f) for f in files_list] + file_names = [os.path.basename(f) for f in normalized_files_list] response = await self.connect_local_files(client_id, file_names) connection_id = response["response"]["temporary_connection_id"] @@ -233,14 +295,14 @@ async def upload_single_file(file_path: str): return await self.upload_file_stream(signed_url, file_path) # Upload files concurrently - tasks = [upload_single_file(file_path) for file_path in files_list] + tasks = [upload_single_file(file_path) for file_path in normalized_files_list] results = await asyncio.gather(*tasks, return_exceptions=True) # Check for errors failed_files = [] for i, result in enumerate(results): if isinstance(result, Exception): - failed_files.append((files_list[i], str(result))) + failed_files.append((normalized_files_list[i], str(result))) if failed_files: error_msg = ( @@ -256,16 +318,13 @@ async def get_dataset(self, workspace_id: str, dataset_id: str) -> Dict[str, Any """ Async version of get_dataset. """ - await self._ensure_session() - url = f"{constants.BASE_URL}/datasets/{dataset_id}" params = {"client_id": workspace_id, "uuid": str(uuid.uuid4())} headers = self._build_headers( extra_headers={"Origin": constants.ALLOWED_ORIGINS} ) - async with self._session.get(url, params=params, headers=headers) as response: - return await self._handle_response(response) + return await self._request("GET", url, params=params, headers=headers) async def create_dataset( self, @@ -275,8 +334,6 @@ async def create_dataset( """ Async version of create_dataset. """ - await self._ensure_session() - try: # Validate data_type if dataset_config.get("data_type") not in constants.DATA_TYPES: @@ -298,7 +355,7 @@ async def create_dataset( extra_headers={"content-type": "application/json"}, ) - payload = { + payload: Dict[str, Any] = { "dataset_name": dataset_config["dataset_name"], "dataset_description": dataset_config.get("dataset_description", ""), "data_type": dataset_config["data_type"], @@ -307,12 +364,16 @@ async def create_dataset( "client_id": dataset_config["client_id"], } - async with self._session.post( - url, params=params, headers=headers, json=payload - ) as response: - response_data = await self._handle_response(response, unique_id) - dataset_id = response_data["response"]["dataset_id"] - return {"response": "success", "dataset_id": dataset_id} + response_data = await self._request( + "POST", + url, + params=params, + headers=headers, + json=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}") diff --git a/labellerr/client.py b/labellerr/client.py index aaa7281..87db582 100644 --- a/labellerr/client.py +++ b/labellerr/client.py @@ -6,18 +6,60 @@ import os import time import uuid -from concurrent.futures import ThreadPoolExecutor, as_completed -from multiprocessing import cpu_count +from dataclasses import dataclass +from typing import Any, Dict, List, Union import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from . import constants, gcs, utils, client_utils + +from . import client_utils, constants, gcs, schemas +from .core.datasets.datasets import DataSets from .exceptions import LabellerrError +from .utils import validate_params +from .validators import auto_log_and_handle_errors + +create_dataset_parameters: Dict[str, Any] = {} + + +@auto_log_and_handle_errors( + include_params=False, + exclude_methods=[ + "close", + "validate_rotation_config", + "get_total_folder_file_count_and_total_size", + "get_total_file_count_and_total_size", + ], +) +@dataclass +class KeyFrame: + """ + Represents a key frame with validation. + """ + + frame_number: int + is_manual: bool = True + method: str = "manual" + source: str = "manual" -# python -m unittest discover -s tests --run -# python setup.py sdist bdist_wheel -- build -create_dataset_parameters = {} + def __post_init__(self): + # Validate frame_number + if not isinstance(self.frame_number, int): + raise ValueError("frame_number must be an integer") + if self.frame_number < 0: + raise ValueError("frame_number must be non-negative") + + # Validate is_manual + if not isinstance(self.is_manual, bool): + raise ValueError("is_manual must be a boolean") + + # Validate method + if not isinstance(self.method, str): + raise ValueError("method must be a string") + + # Validate source + if not isinstance(self.source, str): + raise ValueError("source must be a string") class LabellerrClient: @@ -53,28 +95,39 @@ def __init__( if enable_connection_pooling: self._setup_session() + # Initialize DataSets handler for dataset-related operations + self.datasets = DataSets(api_key, api_secret, self) + def _setup_session(self): """ Set up requests session with connection pooling for better performance. """ self._session = requests.Session() - if HTTPAdapter and Retry: + if HTTPAdapter is not None and Retry is not None: # Configure retry strategy - retry_strategy = Retry( - total=3, - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=[ - "HEAD", - "GET", - "PUT", - "DELETE", - "OPTIONS", - "TRACE", - "POST", - ], - backoff_factor=1, - ) + retry_kwargs = { + "total": 3, + "status_forcelist": [429, 500, 502, 503, 504], + "backoff_factor": 1, + } + + methods = [ + "HEAD", + "GET", + "PUT", + "DELETE", + "OPTIONS", + "TRACE", + "POST", + ] + + try: + # Prefer modern param if available + retry_strategy = Retry(allowed_methods=methods, **retry_kwargs) + except TypeError: + # Fallback for older urllib3 + retry_strategy = Retry(**retry_kwargs) # Configure connection pooling adapter = HTTPAdapter( @@ -86,18 +139,6 @@ def _setup_session(self): self._session.mount("http://", adapter) self._session.mount("https://", adapter) - def _make_request(self, method, url, **kwargs): - """ - Make HTTP request using session if available, otherwise use requests directly. - """ - # Set default timeout if not provided - kwargs.setdefault("timeout", (30, 300)) # connect, read - - if self._session: - return self._session.request(method, url, **kwargs) - else: - return requests.request(method, url, **kwargs) - def close(self): """ Close the session and cleanup resources. @@ -114,60 +155,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close() - def _build_headers(self, client_id=None, extra_headers=None): - """ - Builds standard headers for API requests. - - :param client_id: Optional client ID to include in headers - :param extra_headers: Optional dictionary of additional headers - :return: Dictionary of headers - """ - return client_utils.build_headers( - api_key=self.api_key, - api_secret=self.api_secret, - source="sdk", - client_id=client_id, - extra_headers=extra_headers, - ) - - def _handle_response(self, response, request_id=None, success_codes=None): - """ - Standardized response handling with consistent error patterns. - - :param response: requests.Response object - :param request_id: Optional request tracking ID - :param success_codes: Optional list of success status codes (default: [200, 201]) - :return: JSON response data for successful requests - :raises LabellerrError: For non-successful responses - """ - if success_codes is None: - success_codes = [200, 201] - - if response.status_code in success_codes: - try: - return response.json() - except ValueError: - # Handle cases where response is successful but not JSON - raise LabellerrError(f"Expected JSON response but got: {response.text}") - elif 400 <= response.status_code < 500: - try: - error_data = response.json() - raise LabellerrError( - {"error": error_data, "code": response.status_code} - ) - except ValueError: - raise LabellerrError( - {"error": response.text, "code": response.status_code} - ) - else: - raise LabellerrError( - { - "status": "internal server error", - "message": "Please contact support with the request tracking id", - "request_id": request_id or str(uuid.uuid4()), - } - ) - def _handle_upload_response(self, response, request_id=None): """ Specialized error handling for upload operations that may have different success patterns. @@ -183,7 +170,7 @@ def _handle_upload_response(self, response, request_id=None): raise LabellerrError(f"Failed to parse response: {response.text}") if response.status_code not in [200, 201]: - if response.status_code >= 400 and response.status_code < 500: + if 400 <= response.status_code < 500: raise LabellerrError( {"error": response_data, "code": response.status_code} ) @@ -216,47 +203,378 @@ def _handle_gcs_response(self, response, operation_name="GCS operation"): f"{operation_name} failed: {response.status_code} - {response.text}" ) + def _request(self, method, url, **kwargs): + """ + Wrapper around client_utils.request for backward compatibility. + + :param method: HTTP method + :param url: Request URL + :param kwargs: Additional arguments + :return: Response data + """ + return client_utils.request(method, url, **kwargs) + + def _make_request(self, method, url, **kwargs): + """ + Make an HTTP request using the configured session or requests library. + + :param method: HTTP method (GET, POST, etc.) + :param url: Request URL + :param kwargs: Additional arguments to pass to requests + :return: Response object + """ + if self._session: + return self._session.request(method, url, **kwargs) + else: + return requests.request(method, url, **kwargs) + + def _handle_response(self, response, request_id=None): + """ + Handle API response and extract data or raise errors. + + :param response: requests.Response object + :param request_id: Optional request tracking ID + :return: Response data + """ + return client_utils.handle_response(response, request_id) + def get_direct_upload_url(self, file_name, client_id, purpose="pre-annotations"): """ Get the direct upload URL for the given file names. - :param file_names: The list of file names. + :param file_name: The list of file names. :param client_id: The ID of the client. + :param purpose: The purpose of the URL. :return: The response from the API. """ url = f"{constants.BASE_URL}/connectors/direct-upload-url?client_id={client_id}&purpose={purpose}&file_name={file_name}" - headers = self._build_headers(client_id=client_id) - - response = self._make_request("GET", url, headers=headers) + headers = client_utils.build_headers( + client_id=client_id, api_key=self.api_key, api_secret=self.api_secret + ) try: - response_data = self._handle_response(response, success_codes=[200]) + response_data = client_utils.request( + "GET", url, headers=headers, success_codes=[200] + ) return response_data["response"] except Exception as e: - logging.exception(f"Error getting direct upload url: {response.text} {e}") + logging.exception(f"Error getting direct upload url: {e}") raise + def create_aws_connection( + self, + client_id: str, + aws_access_key: str, + aws_secrets_key: str, + s3_path: str, + data_type: str, + name: str, + description: str, + connection_type: str = "import", + ): + """ + AWS S3 connector and, if valid, save the connection. + :param client_id: The ID of the client. + :param aws_access_key: The AWS access key. + :param aws_secrets_key: The AWS secrets key. + :param s3_path: The S3 path. + :param data_type: The data type. + :param name: The name of the connection. + :param description: The description. + :param connection_type: The connection type. + + """ + # Validate parameters using Pydantic + params = schemas.AWSConnectionParams( + client_id=client_id, + aws_access_key=aws_access_key, + aws_secrets_key=aws_secrets_key, + s3_path=s3_path, + data_type=data_type, + name=name, + description=description, + connection_type=connection_type, + ) + + request_uuid = str(uuid.uuid4()) + test_connection_url = ( + f"{constants.BASE_URL}/connectors/connections/test" + f"?client_id={params.client_id}&uuid={request_uuid}" + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"email_id": self.api_key}, + ) + + aws_credentials_json = json.dumps( + { + "access_key_id": params.aws_access_key, + "secret_access_key": params.aws_secrets_key, + } + ) + + test_request = { + "credentials": aws_credentials_json, + "connector": "s3", + "path": params.s3_path, + "connection_type": params.connection_type, + "data_type": params.data_type, + } + + client_utils.request( + "POST", + test_connection_url, + headers=headers, + data=test_request, + request_id=request_uuid, + ) + + create_url = ( + f"{constants.BASE_URL}/connectors/connections/create" + f"?uuid={request_uuid}&client_id={params.client_id}" + ) + + create_request = { + "client_id": params.client_id, + "connector": "s3", + "name": params.name, + "description": params.description, + "connection_type": params.connection_type, + "data_type": params.data_type, + "credentials": aws_credentials_json, + } + + return client_utils.request( + "POST", + create_url, + headers=headers, + data=create_request, + request_id=request_uuid, + ) + + def create_gcs_connection( + self, + client_id: str, + gcs_cred_file: str, + gcs_path: str, + data_type: str, + name: str, + description: str, + connection_type: str = "import", + credentials: str = "svc_account_json", + ): + """ + Create/test a GCS connector connection (multipart/form-data) + :param client_id: The ID of the client. + :param gcs_cred_file: Path to the GCS service account JSON file. + :param gcs_path: GCS path like gs://bucket/path + :param data_type: Data type, e.g. "image", "video". + :param name: Name of the connection + :param description: Description of the connection + :param connection_type: "import" or "export" (default: import) + :param credentials: Credential type (default: svc_account_json) + :return: Parsed JSON response + """ + # Validate parameters using Pydantic + params = schemas.GCSConnectionParams( + client_id=client_id, + gcs_cred_file=gcs_cred_file, + gcs_path=gcs_path, + data_type=data_type, + name=name, + description=description, + connection_type=connection_type, + credentials=credentials, + ) + + request_uuid = str(uuid.uuid4()) + test_url = ( + f"{constants.BASE_URL}/connectors/connections/test" + f"?client_id={params.client_id}&uuid={request_uuid}" + ) + + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + client_id=params.client_id, + extra_headers={"email_id": self.api_key}, + ) + + test_request = { + "credentials": params.credentials, + "connector": "gcs", + "path": params.gcs_path, + "connection_type": params.connection_type, + "data_type": params.data_type, + } + + with open(params.gcs_cred_file, "rb") as fp: + test_files = { + "attachment_files": ( + os.path.basename(params.gcs_cred_file), + fp, + "application/json", + ) + } + client_utils.request( + "POST", + test_url, + headers=headers, + data=test_request, + files=test_files, + request_id=request_uuid, + ) + + # If test passed, create/save the connection + # use same uuid to track request + create_url = ( + f"{constants.BASE_URL}/connectors/connections/create" + f"?uuid={request_uuid}&client_id={params.client_id}" + ) + + create_request = { + "client_id": params.client_id, + "connector": "gcs", + "name": params.name, + "description": params.description, + "connection_type": params.connection_type, + "data_type": params.data_type, + "credentials": params.credentials, + } + + with open(params.gcs_cred_file, "rb") as fp: + create_files = { + "attachment_files": ( + os.path.basename(params.gcs_cred_file), + fp, + "application/json", + ) + } + return client_utils.request( + "POST", + create_url, + headers=headers, + data=create_request, + files=create_files, + request_id=request_uuid, + ) + + def list_connection( + self, client_id: str, connection_type: str, connector: str = None + ): + request_uuid = str(uuid.uuid4()) + list_connection_url = ( + f"{constants.BASE_URL}/connectors/connections/list" + f"?client_id={client_id}&uuid={request_uuid}&connection_type={connection_type}" + ) + + if connector: + list_connection_url += f"&connector={connector}" + + 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}, + ) + + return client_utils.request( + "GET", list_connection_url, headers=headers, request_id=request_uuid + ) + + def delete_connection(self, client_id: str, connection_id: str): + """ + Deletes a connector connection by ID. + + :param client_id: The ID of the client. + :param connection_id: The ID of the connection to delete. + :return: Parsed JSON response + """ + # Validate parameters using Pydantic + params = schemas.DeleteConnectionParams( + client_id=client_id, connection_id=connection_id + ) + request_uuid = str(uuid.uuid4()) + delete_url = ( + f"{constants.BASE_URL}/connectors/connections/delete" + f"?client_id={params.client_id}&uuid={request_uuid}" + ) + + 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", + "email_id": self.api_key, + }, + ) + + payload = json.dumps({"connection_id": params.connection_id}) + + return client_utils.request( + "POST", delete_url, headers=headers, data=payload, request_id=request_uuid + ) + def connect_local_files(self, client_id, file_names, connection_id=None): """ Connects local files to the API. :param client_id: The ID of the client. :param file_names: The list of file names. + :param connection_id: The ID of the connection. :return: The response from the API. """ url = f"{constants.BASE_URL}/connectors/connect/local?client_id={client_id}" - headers = self._build_headers(client_id=client_id) + headers = client_utils.build_headers( + api_key=self.api_key, api_secret=self.api_secret, client_id=client_id + ) body = {"file_names": file_names} if connection_id is not None: body["temporary_connection_id"] = connection_id - response = self._make_request("POST", url, headers=headers, json=body) - return self._handle_response(response) + return client_utils.request("POST", url, headers=headers, json=body) + + @validate_params(client_id=str, files_list=(str, list)) + def upload_files(self, client_id: str, files_list: Union[str, List[str]]): + """ + Uploads files to the API. + + :param client_id: The ID of the client. + :param files_list: The list of files to upload or a comma-separated string of file paths. + :return: The connection ID from the API. + :raises LabellerrError: If the upload fails. + """ + # Validate parameters using Pydantic + params = schemas.UploadFilesParams(client_id=client_id, files_list=files_list) + try: + # Use validated files_list from Pydantic + files_list = params.files_list + + if len(files_list) == 0: + raise LabellerrError("No files to upload") + + response = self.__process_batch(client_id, files_list) + connection_id = response["response"]["temporary_connection_id"] + return connection_id + 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. + Processes a batch of files for upload. + + :param client_id: The ID of the client + :param files_list: List of file paths to process + :param connection_id: Optional connection ID + :return: Response from connect_local_files """ # Prepare files for upload files = {} @@ -275,60 +593,22 @@ def __process_batch(self, client_id, files_list, connection_id=None): return response - def upload_files(self, client_id, files_list): - """ - Uploads files to the API. - - :param client_id: The ID of the client. - :param dataset_id: The ID of the dataset. - :param data_type: The type of data. - :param files_list: The list of files to upload or a comma-separated string of file paths. - :return: The response from the API. - :raises LabellerrError: If the upload fails. - """ - try: - # Convert string input to list if necessary - if isinstance(files_list, str): - files_list = files_list.split(",") - elif not isinstance(files_list, list): - raise LabellerrError( - "files_list must be either a list or a comma-separated string" - ) - - if len(files_list) == 0: - raise LabellerrError("No files to upload") - - # Validate files exist - for file_path in files_list: - if not os.path.exists(file_path): - raise LabellerrError(f"File does not exist: {file_path}") - if not os.path.isfile(file_path): - raise LabellerrError(f"Path is not a file: {file_path}") - - response = self.__process_batch(client_id, files_list) - connection_id = response["response"]["temporary_connection_id"] - return connection_id - - except Exception as e: - logging.error(f"Failed to upload files : {str(e)}") - raise LabellerrError(f"Failed to upload files : {str(e)}") - def get_dataset(self, workspace_id, dataset_id): """ Retrieves a dataset from the Labellerr API. :param workspace_id: The ID of the workspace. :param dataset_id: The ID of the dataset. - :param project_id: The ID of the project. :return: The dataset as JSON. """ url = f"{constants.BASE_URL}/datasets/{dataset_id}?client_id={workspace_id}&uuid={str(uuid.uuid4())}" - headers = self._build_headers( - extra_headers={"Origin": constants.ALLOWED_ORIGINS} + headers = client_utils.build_headers( + api_key=self.api_key, + api_secret=self.api_secret, + extra_headers={"Origin": constants.ALLOWED_ORIGINS}, ) - response = self._make_request("GET", url, headers=headers) - return self._handle_response(response) + return client_utils.request("GET", url, headers=headers) def update_rotation_count(self): """ @@ -340,7 +620,9 @@ def update_rotation_count(self): 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 = self._build_headers( + 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"}, ) @@ -351,134 +633,177 @@ def update_rotation_count(self): response = requests.request("POST", url, headers=headers, data=payload) logging.info("Rotation configuration updated successfully.") - self._handle_response(response, unique_id) + 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 create_dataset( - self, dataset_config, files_to_upload=None, folder_to_upload=None + def _setup_cloud_connector( + self, connector_type: str, client_id: str, connector_config: dict ): """ - Creates an empty dataset. + Internal method to set up cloud connector (AWS or GCP). - :param dataset_config: A dictionary containing the configuration for the dataset. - :return: A dictionary containing the response status and the ID of the created dataset. + :param connector_type: Type of connector ('aws' or 'gcp') + :param client_id: The ID of the client + :param connector_config: Configuration dictionary for the connector + :return: connection_id from the created connection """ - - try: - # 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}" - ) - - unique_id = str(uuid.uuid4()) - url = f"{constants.BASE_URL}/datasets/create?client_id={dataset_config['client_id']}&uuid={unique_id}" - headers = self._build_headers( - client_id=dataset_config["client_id"], - extra_headers={"content-type": "application/json"}, + if connector_type == "s3": + # AWS connector configuration + aws_access_key = connector_config.get("aws_access_key") + aws_secrets_key = connector_config.get("aws_secrets_key") + s3_path = connector_config.get("s3_path") + data_type = connector_config.get("data_type") + + if not all([aws_access_key, aws_secrets_key, s3_path, data_type]): + raise ValueError("Missing required AWS connector configuration") + + result = self.create_aws_connection( + client_id=client_id, + aws_access_key=str(aws_access_key), + aws_secrets_key=str(aws_secrets_key), + s3_path=str(s3_path), + data_type=str(data_type), + name=connector_config.get("name", f"aws_connector_{int(time.time())}"), + description=connector_config.get( + "description", "Auto-created AWS connector" + ), + connection_type=connector_config.get("connection_type", "import"), ) - if files_to_upload is not None: - try: - connection_id = self.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)}" - ) - 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": "local", - "client_id": dataset_config["client_id"], - } + elif connector_type == "gcp": + # GCP connector configuration + gcs_cred_file = connector_config.get("gcs_cred_file") + gcs_path = connector_config.get("gcs_path") + data_type = connector_config.get("data_type") + + if not all([gcs_cred_file, gcs_path, data_type]): + raise ValueError("Missing required GCS connector configuration") + + result = self.create_gcs_connection( + client_id=client_id, + gcs_cred_file=str(gcs_cred_file), + gcs_path=str(gcs_path), + data_type=str(data_type), + name=connector_config.get("name", f"gcs_connector_{int(time.time())}"), + description=connector_config.get( + "description", "Auto-created GCS connector" + ), + connection_type=connector_config.get("connection_type", "import"), ) - response = requests.request("POST", url, headers=headers, data=payload) - response_data = self._handle_response(response, unique_id) - dataset_id = response_data["response"]["dataset_id"] - - return {"response": "success", "dataset_id": dataset_id} + else: + raise LabellerrError(f"Unsupported cloud connector type: {connector_type}") - except LabellerrError as e: - logging.error(f"Failed to create dataset: {e}") - raise + # Extract connection_id from the response + if isinstance(result, dict) and "response" in result: + return result["response"].get("connection_id") + return None - def get_all_dataset(self, client_id, datatype, project_id, scope): + def enable_multimodal_indexing(self, client_id, dataset_id, is_multimodal=True): """ - Retrieves a dataset by its ID. + Enables or disables multimodal indexing for an existing dataset. - :param client_id: The ID of the client. - :param datatype: The type of data for the dataset. - :return: The dataset as JSON. + :param client_id: The ID of the client + :param dataset_id: The ID of the dataset + :param is_multimodal: Boolean flag to enable (True) or disable (False) multimodal indexing + :return: Dictionary containing indexing status + :raises LabellerrError: If the operation fails """ - # validate parameters - if not isinstance(client_id, str): - raise LabellerrError("client_id must be a string") - if not isinstance(datatype, str): - raise LabellerrError("datatype must be a string") - if not isinstance(project_id, str): - raise LabellerrError("project_id must be a string") - if not isinstance(scope, str): - raise LabellerrError("scope must be a string") - # scope value should on in the list SCOPE_LIST - if scope not in constants.SCOPE_LIST: - raise LabellerrError( - f"scope must be one of {', '.join(constants.SCOPE_LIST)}" - ) + # Validate parameters using Pydantic + params = schemas.EnableMultimodalIndexingParams( + client_id=client_id, + dataset_id=dataset_id, + is_multimodal=is_multimodal, + ) - # get dataset - try: - unique_id = str(uuid.uuid4()) - url = f"{self.base_url}/datasets/list?client_id={client_id}&data_type={datatype}&permission_level={scope}&project_id={project_id}&uuid={unique_id}" - headers = self._build_headers( - client_id=client_id, extra_headers={"content-type": "application/json"} - ) + unique_id = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/search/multimodal_index?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"}, + ) - response = requests.request("GET", url, headers=headers) - return self._handle_response(response, unique_id) - except LabellerrError as e: - logging.error(f"Failed to retrieve dataset: {e}") - raise + payload = json.dumps( + { + "dataset_id": str(params.dataset_id), + "client_id": params.client_id, + "is_multimodal": params.is_multimodal, + } + ) - def get_total_folder_file_count_and_total_size(self, folder_path, data_type): + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def get_multimodal_indexing_status(self, client_id, dataset_id): """ - Retrieves the total count and size of files in a folder using memory-efficient iteration. + Retrieves the current multimodal indexing status for a dataset. - :param folder_path: The path to the folder. - :param data_type: The type of data for the files. - :return: The total count and size of the files. + :param client_id: The ID of the client + :param dataset_id: The ID of the dataset + :return: Dictionary containing indexing status and configuration + :raises LabellerrError: If the operation fails """ - total_file_count = 0 - total_file_size = 0 - files_list = [] + # Validate parameters using Pydantic + params = schemas.GetMultimodalIndexingStatusParams( + client_id=client_id, + dataset_id=dataset_id, + ) - # Use os.scandir for better performance and memory efficiency - def scan_directory(directory): - nonlocal total_file_count, total_file_size - try: + url = ( + f"{constants.BASE_URL}/search/multimodal_index?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( + { + "dataset_id": str(params.dataset_id), + "client_id": params.client_id, + "get_status": True, + } + ) + + result = client_utils.request("POST", url, headers=headers, data=payload) + + # If the response is null or empty, provide a meaningful default status + if result.get("response") is None: + result["response"] = { + "enabled": False, + "modalities": [], + "indexing_type": None, + "status": "not_configured", + "message": "Multimodal indexing has not been configured for this dataset", + } + + return result + + def get_total_folder_file_count_and_total_size(self, folder_path, data_type): + """ + Retrieves the total count and size of files in a folder using memory-efficient iteration. + + :param folder_path: The path to the folder. + :param data_type: The type of data for the files. + :return: The total count and size of the files. + """ + total_file_count = 0 + total_file_size = 0 + files_list = [] + + # Use os.scandir for better performance and memory efficiency + def scan_directory(directory): + nonlocal total_file_count, total_file_size + try: with os.scandir(directory) as entries: for entry in entries: if entry.is_file(): @@ -520,7 +845,7 @@ def get_total_file_count_and_total_size(self, files_list, data_type): if file_path is None: continue try: - # check if the file extention matching based on datatype + # check if the file extension matching based on datatype if not any( file_path.endswith(ext) for ext in constants.DATA_TYPE_FILE_EXT[data_type] @@ -548,58 +873,18 @@ def get_all_project_per_client_id(self, client_id): unique_id = str(uuid.uuid4()) url = f"{self.base_url}/project_drafts/projects/detailed_list?client_id={client_id}&uuid={unique_id}" - headers = self._build_headers( - client_id=client_id, extra_headers={"content-type": "application/json"} + 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 self._handle_response(response, unique_id) + return client_utils.handle_response(response, unique_id) except Exception as e: logging.error(f"Failed to retrieve projects: {str(e)}") - raise LabellerrError(f"Failed to retrieve projects: {str(e)}") - - 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 = self._build_headers( - client_id=client_id, extra_headers={"content-type": "application/json"} - ) - - try: - response = requests.request( - "POST", url, headers=headers, data=guide_payload - ) - response_data = self._handle_response(response, 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 LabellerrError( - f"Failed to update project annotation guideline: {str(e)}" - ) - - 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) + raise def _upload_preannotation_sync( self, project_id, client_id, annotation_format, annotation_file @@ -627,7 +912,8 @@ def _upload_preannotation_sync( ) client_utils.validate_annotation_format(annotation_format, annotation_file) - url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}" + 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}" @@ -649,11 +935,14 @@ def _upload_preannotation_sync( # 'email_id': self.api_key # }, data=payload, files=files) url += "&gcs_path=" + gcs_path - headers = self._build_headers( - client_id=client_id, extra_headers={"email_id": self.api_key} + 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) + response_data = self._handle_upload_response(response, request_uuid) # read job_id from the response job_id = response_data["response"]["job_id"] @@ -662,10 +951,15 @@ def _upload_preannotation_sync( self.project_id = project_id logging.info(f"Preannotation upload successful. Job ID: {job_id}") - return self.preannotation_job_status() + + # 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 LabellerrError(f"Failed to upload preannotation: {str(e)}") + raise def upload_preannotation_by_project_id_async( self, project_id, client_id, annotation_format, annotation_file @@ -699,7 +993,11 @@ def upload_and_monitor(): f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" ) - url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}" + 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): @@ -734,11 +1032,14 @@ def upload_and_monitor(): # 'email_id': self.api_key # }, data=payload, files=files) url += "&gcs_path=" + gcs_path - headers = self._build_headers( - client_id=client_id, extra_headers={"email_id": self.api_key} + 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) + response_data = self._handle_upload_response(response, request_uuid) # read job_id from the response job_id = response_data["response"]["job_id"] @@ -746,10 +1047,12 @@ def upload_and_monitor(): self.job_id = job_id self.project_id = project_id - logging.info(f"Preannotation upload successful. Job ID: {job_id}") + logging.info(f"Pre annotation upload successful. Job ID: {job_id}") # Now monitor the status - headers = self._build_headers( + 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}, ) @@ -774,33 +1077,42 @@ def upload_and_monitor(): logging.error( f"Failed to get preannotation job status: {str(e)}" ) - raise LabellerrError( - f"Failed to get preannotation job status: {str(e)}" - ) + raise except Exception as e: logging.exception(f"Failed to upload preannotation: {str(e)}") - raise LabellerrError(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): + def preannotation_job_status_async(self, max_retries=60, retry_interval=5): """ - Get the status of a preannotation job asynchronously. + 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 = self._build_headers( + 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 = {} - while True: + retry_count = 0 + + while retry_count < max_retries: try: response = requests.request( "GET", url, headers=headers, data=payload @@ -809,16 +1121,35 @@ def check_status(): # 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 - logging.info("retrying after 5 seconds . . .") - time.sleep(5) + 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) @@ -853,7 +1184,8 @@ def upload_preannotation_by_project_id( f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" ) - url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}" + 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): @@ -864,13 +1196,16 @@ def upload_preannotation_by_project_id( payload = {} with open(annotation_file, "rb") as f: files = [("file", (file_name, f, "application/octet-stream"))] - headers = self._build_headers( - client_id=client_id, extra_headers={"email_id": self.api_key} + 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) + response_data = self._handle_upload_response(response, request_uuid) logging.debug(f"response_data: {response_data}") # read job_id from the response @@ -881,53 +1216,62 @@ def upload_preannotation_by_project_id( logging.info(f"Preannotation upload successful. Job ID: {job_id}") - future = self.preannotation_job_status_async() + # 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 LabellerrError(f"Failed to upload preannotation: {str(e)}") + raise def create_local_export(self, project_id, client_id, export_config): - unique_id = client_utils.generate_request_id() - - if project_id is None: - raise LabellerrError("project_id cannot be null") - - if client_id is None: - raise LabellerrError("client_id cannot be null") - - if export_config is None: - raise LabellerrError("export_config cannot be null") + """ + 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) - try: - export_config.update( - {"export_destination": "local", "question_ids": ["all"]} - ) - payload = json.dumps(export_config) - headers = self._build_headers( - extra_headers={ - "Origin": constants.ALLOWED_ORIGINS, - "Content-Type": "application/json", - } - ) + unique_id = client_utils.generate_request_id() + export_config.update({"export_destination": "local", "question_ids": ["all"]}) - response = requests.post( - f"{self.base_url}/sdk/export/files?project_id={project_id}&client_id={client_id}", - headers=headers, - data=payload, - ) + 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 self._handle_response(response, unique_id) - except requests.exceptions.RequestException as e: - logging.error(f"Failed to create local export: {str(e)}") - raise LabellerrError(f"Failed to create local export: {str(e)}") + 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 = self._build_headers( - client_id=client_id, extra_headers={"Content-Type": "application/json"} + 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.get( @@ -949,31 +1293,37 @@ def fetch_download_url(self, project_id, uuid, export_id, client_id): ) except requests.exceptions.RequestException as e: logging.error(f"Failed to download export: {str(e)}") - raise LabellerrError(f"Failed to download export: {str(e)}") + raise except Exception as e: logging.error(f"Unexpected error in download_function: {str(e)}") - raise LabellerrError(f"Unexpected error in download_function: {str(e)}") + raise - def check_export_status(self, project_id, report_ids, client_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 or not isinstance(report_ids, list): - raise LabellerrError("report_ids must be a non-empty list") + 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 = self._build_headers( - client_id=client_id, extra_headers={"Content-Type": "application/json"} + 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 = self._handle_response(response, request_uuid) + result = client_utils.handle_response(response, request_uuid) # Now process each report_id for status_item in result.get("status", []): @@ -995,343 +1345,679 @@ def check_export_status(self, project_id, report_ids, client_id): except requests.exceptions.RequestException as e: logging.error(f"Failed to check export status: {str(e)}") - raise LabellerrError(f"Failed to check export status: {str(e)}") + raise except Exception as e: logging.error(f"Unexpected error checking export status: {str(e)}") - raise LabellerrError(f"Unexpected error checking export status: {str(e)}") + raise - def create_project( + def create_template(self, client_id, data_type, template_name, questions): + """ + Creates an annotation template with the given configuration. + + :param client_id: The ID of the client. + :param data_type: The type of data for the template (image, video, etc.). + :param template_name: The name of the template. + :param questions: List of questions/annotations for the template. + :return: The response from the API containing template details. + :raises LabellerrError: If the creation fails. + """ + # Validate parameters using Pydantic + params = schemas.CreateTemplateParams( + client_id=client_id, + data_type=data_type, + template_name=template_name, + questions=questions, + ) + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/annotations/create_template?client_id={params.client_id}&data_type={params.data_type}&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( + { + "templateName": params.template_name, + "questions": [q.model_dump() for q in params.questions], + } + ) + + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + + def create_user( self, - project_name, - data_type, client_id, - dataset_id, - annotation_template_id, - rotation_config, - created_by=None, + first_name, + last_name, + email_id, + projects, + roles, + work_phone="", + job_title="", + language="en", + timezone="GMT", ): """ - Creates a project with the given configuration. + 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 """ - url = f"{constants.BASE_URL}/projects/create?client_id={client_id}" + # 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( { - "project_name": project_name, - "attached_datasets": [dataset_id], - "data_type": data_type, - "annotation_template_id": annotation_template_id, - "rotations": rotation_config, - "created_by": created_by, + "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, } ) - headers = self._build_headers( + 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={ - "Origin": constants.ALLOWED_ORIGINS, - "Content-Type": "application/json", - } + "content-type": "application/json", + "accept": "application/json, text/plain, */*", + }, ) - # print(f"{payload}") + # 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 + ) - response = requests.post(url, headers=headers, data=payload) - response_data = response.json() + 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}" - # print(f"{response_data}") + 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, */*", + }, + ) - if "error" in response_data and response_data["error"]: - error_details = response_data["error"] - error_msg = ( - f"Validation Error: {response_data.get('message', 'Unknown error')}" - ) - for error in error_details: - error_msg += f"\n- Field '{error['field']}': {error['message']}" - raise LabellerrError(error_msg) + # 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 + ) - return response_data + 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}" - def initiate_create_project(self, payload): + 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): """ - Orchestrates project creation by handling dataset creation, annotation guidelines, - and final project setup. + 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 + ) - try: - # validate all the parameters - required_params = [ - "client_id", - "dataset_name", - "dataset_description", - "data_type", - "created_by", - "project_name", - "annotation_guide", - "autolabel", - ] - for param in required_params: - if param not in payload: - raise LabellerrError(f"Required parameter {param} is missing") + 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}" - if param == "client_id" and not isinstance(payload[param], str): - raise LabellerrError("client_id must be a non-empty string") - - if param == "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" - ) + 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"}, + ) - 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" - ) + payload_data = {"email_id": params.email_id, "uuid": unique_id} - 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"]) + payload = json.dumps(payload_data) + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) - if payload["data_type"] not in constants.DATA_TYPES: - raise LabellerrError( - f"Invalid data_type. Must be one of {constants.DATA_TYPES}" - ) + # 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, + ) - logging.info("Rotation configuration validated . . .") + 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}" - 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"), - ) + 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"}, + ) - dataset_id = dataset_response["dataset_id"] + payload_data = { + "email_id": params.email_id, + "new_role_id": params.new_role_id, + "uuid": unique_id, + } - def dataset_ready(): - try: - dataset_status = self.get_dataset(payload["client_id"], dataset_id) + payload = json.dumps(payload_data) + return client_utils.request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) - if isinstance(dataset_status, dict): + 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, + ) - if "response" in dataset_status: - return ( - dataset_status["response"].get("status_code", 200) - == 300 - ) - else: + 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}" - 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, - ) + 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"}, + ) - logging.info("Dataset created and ready for use") + payload = json.dumps( + { + "search_queries": params.search_queries, + "size": params.size, + "next_search_after": params.next_search_after, + } + ) - annotation_template_id = self.create_annotation_guideline( - payload["client_id"], - payload["annotation_guide"], - payload["project_name"], - payload["data_type"], - ) - logging.info("Annotation guidelines created") - - project_response = self.create_project( - project_name=payload["project_name"], - data_type=payload["data_type"], - client_id=payload["client_id"], - dataset_id=dataset_id, - annotation_template_id=annotation_template_id, - rotation_config=payload["rotation_config"], - created_by=payload["created_by"], + 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] + ): + """ + Links key frames to a file in a project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :param file_id: The ID of the file + :param key_frames: List of KeyFrame objects to link + :return: Response from the API + """ + try: + unique_id = str(uuid.uuid4()) + url = f"{self.base_url}/actions/add_update_keyframes?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"}, ) - return { - "status": "success", - "message": "Project created successfully", - "project_id": project_response, + body = { + "project_id": project_id, + "file_id": file_id, + "keyframes": [ + kf.__dict__ if isinstance(kf, KeyFrame) else kf for kf in key_frames + ], } + response = self._make_request("POST", url, headers=headers, json=body) + return self._handle_response(response, unique_id) + except LabellerrError as e: - logging.error(f"Project creation failed: {str(e)}") - raise + raise e except Exception as e: - logging.exception("Unexpected error in project creation") - raise LabellerrError(f"Project creation failed: {str(e)}") from e + raise LabellerrError(f"Failed to link key frames: {str(e)}") - def upload_folder_files_to_dataset(self, data_config): + @validate_params(client_id=str, project_id=str) + def delete_key_frames(self, client_id: str, project_id: str): """ - Uploads local files from a folder to a dataset using parallel processing. + Deletes key frames from a project. - :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 + :param client_id: The ID of the client + :param project_id: The ID of the project + :return: Response from the API """ 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)}" - ) + unique_id = str(uuid.uuid4()) + url = f"{self.base_url}/actions/delete_keyframes?project_id={project_id}&uuid={unique_id}&client_id={client_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"}, + ) - # 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']}" - ) + response = self._make_request("POST", url, headers=headers) + return self._handle_response(response, unique_id) - success_queue = [] - fail_queue = [] + except LabellerrError as e: + raise e + except Exception as e: + raise LabellerrError(f"Failed to delete key frames: {str(e)}") - try: - # Get files from folder - total_file_count, total_file_volumn, filenames = ( - self.get_total_folder_file_count_and_total_size( - data_config["folder_path"], data_config["data_type"] - ) - ) - except Exception as e: - raise LabellerrError(f"Failed to analyze folder contents: {str(e)}") + # ===== Dataset-related methods (delegated to DataSets) ===== - # 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" - ) + 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) - logging.info(f"Total file count: {total_file_count}") - logging.info(f"Total file size: {total_file_volumn/1024/1024:.1f} MB") + def create_annotation_guideline( + self, client_id, questions, template_name, data_type + ): + """ + Creates an annotation guideline for a project. + Delegates to the DataSets handler. + """ + return self.datasets.create_annotation_guideline( + client_id, questions, template_name, data_type + ) - # Use generator for memory-efficient batch creation - def create_batches(): - current_batch = [] - current_batch_size = 0 + def validate_rotation_config(self, rotation_config): + """ + Validates a rotation configuration. + Delegates to the DataSets handler. + """ + return self.datasets.validate_rotation_config(rotation_config) - 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) + 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. + Delegates to the DataSets handler. + """ + return self.datasets.create_dataset( + dataset_config, files_to_upload, folder_to_upload, connector_config + ) - if current_batch: - yield current_batch + def delete_dataset(self, client_id, dataset_id): + """ + Deletes a dataset from the system. + Delegates to the DataSets handler. + """ + return self.datasets.delete_dataset(client_id, dataset_id) - # Convert generator to list for ThreadPoolExecutor - batches = list(create_batches()) + def upload_folder_files_to_dataset(self, data_config): + """ + Uploads local files from a folder to a dataset using parallel processing. + Delegates to the DataSets handler. + """ + return self.datasets.upload_folder_files_to_dataset(data_config) - if not batches: - raise LabellerrError( - "No valid files found to upload in the specified folder" - ) + def initiate_attach_dataset_to_project(self, client_id, project_id, dataset_id): + """ + Orchestrates attaching a dataset to a project. + Delegates to the DataSets handler. + """ + return self.datasets.attach_dataset_to_project( + client_id, project_id, dataset_id=dataset_id + ) - logging.info(f"CPU count: {cpu_count()}, Batch Count: {len(batches)}") + 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. - # Calculate optimal number of workers based on CPU count and batch count - max_workers = min( - 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) + :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 + """ + return self.datasets.attach_dataset_to_project( + client_id, project_id, dataset_ids=dataset_ids + ) - if not success_queue and fail_queue: - raise LabellerrError( - "All file uploads failed. Check individual file errors above." - ) + def initiate_detach_dataset_from_project(self, client_id, project_id, dataset_id): + """ + Orchestrates detaching a dataset from a project. + Delegates to the DataSets handler. + """ + return self.datasets.detach_dataset_from_project( + client_id, project_id, dataset_id=dataset_id + ) - return { - "connection_id": connection_id, - "success": success_queue, - "fail": fail_queue, - } + def initiate_detach_datasets_from_project(self, client_id, project_id, dataset_ids): + """ + Orchestrates detaching multiple datasets from a project (batch operation). + Delegates to the DataSets handler. - except LabellerrError as e: - raise e - except Exception as e: - raise LabellerrError(f"Failed to upload files: {str(e)}") + :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 + """ + return self.datasets.detach_dataset_from_project( + client_id, project_id, dataset_ids=dataset_ids + ) diff --git a/labellerr/client_utils.py b/labellerr/client_utils.py index e786889..dc5f65d 100644 --- a/labellerr/client_utils.py +++ b/labellerr/client_utils.py @@ -3,8 +3,12 @@ """ import uuid -from typing import Dict, Optional, Any +from typing import Any, Dict, Optional + +import requests + from . import constants +from .exceptions import LabellerrError def build_headers( @@ -54,19 +58,27 @@ def validate_rotation_config(rotation_config: Dict[str, Any]) -> None: client_review_rotation_count = rotation_config.get("client_review_rotation_count") # Validate review_rotation_count - if review_rotation_count != 1: + if int(review_rotation_count or 0) != 1: raise LabellerrError("review_rotation_count must be 1") # Validate client_review_rotation_count based on annotation_rotation_count - if annotation_rotation_count == 0 and client_review_rotation_count != 0: + if ( + int(annotation_rotation_count or 0) == 0 + and int(client_review_rotation_count or 0) != 0 + ): raise LabellerrError( "client_review_rotation_count must be 0 when annotation_rotation_count is 0" ) - elif annotation_rotation_count == 1 and client_review_rotation_count not in [0, 1]: + elif int(annotation_rotation_count or 0) == 1 and int( + client_review_rotation_count or 0 + ) not in [0, 1]: raise LabellerrError( "client_review_rotation_count can only be 0 or 1 when annotation_rotation_count is 1" ) - elif annotation_rotation_count > 1 and client_review_rotation_count != 0: + elif ( + int(annotation_rotation_count or 0) > 1 + and int(client_review_rotation_count or 0) != 0 + ): raise LabellerrError( "client_review_rotation_count must be 0 when annotation_rotation_count is greater than 1" ) @@ -96,6 +108,7 @@ def validate_file_exists(file_path: str) -> str: :raises LabellerrError: If file doesn't exist """ import os + from .exceptions import LabellerrError if os.path.exists(file_path): @@ -113,6 +126,7 @@ def validate_annotation_format(annotation_format: str, annotation_file: str) -> :raises LabellerrError: If format/extension mismatch """ import os + from .exceptions import LabellerrError if annotation_format not in constants.ANNOTATION_FORMAT: @@ -168,3 +182,87 @@ def validate_export_config(export_config: Dict[str, Any]) -> None: def generate_request_id() -> str: """Generate a unique request ID.""" return str(uuid.uuid4()) + + +def handle_response(response, request_id=None, success_codes=None): + """ + Legacy method for handling response objects directly. + Kept for backward compatibility with special response handlers. + + :param response: requests.Response object + :param request_id: Optional request tracking ID + :param success_codes: Optional list of success status codes (default: [200, 201]) + :return: JSON response data for successful requests + :raises LabellerrError: For non-successful responses + """ + if success_codes is None: + success_codes = [200, 201] + + if response.status_code in success_codes: + try: + return response.json() + except ValueError: + # Handle cases where response is successful but not JSON + raise LabellerrError(f"Expected JSON response but got: {response.text}") + elif 400 <= response.status_code < 500: + try: + error_data = response.json() + raise LabellerrError({"error": error_data, "code": response.status_code}) + except ValueError: + raise LabellerrError({"error": response.text, "code": response.status_code}) + else: + raise LabellerrError( + { + "status": "internal server error", + "message": "Please contact support with the request tracking id", + "request_id": request_id or str(uuid.uuid4()), + } + ) + + +def request(method, url, request_id=None, success_codes=None, **kwargs): + """ + Make HTTP request and handle response in a single method. + + :param method: HTTP method (GET, POST, etc.) + :param url: Request URL + :param request_id: Optional request tracking ID (auto-generated if not provided) + :param success_codes: Optional list of success status codes (default: [200, 201]) + :param kwargs: Additional arguments to pass to requests + :return: JSON response data for successful requests + :raises LabellerrError: For non-successful responses + """ + # Generate request_id if not provided + if request_id is None: + request_id = str(uuid.uuid4()) + + # Set default timeout if not provided + kwargs.setdefault("timeout", (30, 300)) # connect, read + + # Make the request[ + response = requests.request(method, url, **kwargs) + + # Handle the response + if success_codes is None: + success_codes = [200, 201] + + if response.status_code in success_codes: + try: + return response.json() + except ValueError: + # Handle cases where response is successful but not JSON + raise LabellerrError(f"Expected JSON response but got: {response.text}") + elif 400 <= response.status_code < 500: + try: + error_data = response.json() + raise LabellerrError({"error": error_data, "code": response.status_code}) + except ValueError: + raise LabellerrError({"error": response.text, "code": response.status_code}) + else: + raise LabellerrError( + { + "status": "internal server error", + "message": "Please contact support with the request tracking id", + "request_id": request_id, + } + ) diff --git a/labellerr/config.py b/labellerr/config.py index ada423a..2834055 100644 --- a/labellerr/config.py +++ b/labellerr/config.py @@ -1,3 +1,4 @@ """This is to be removed, should be in constants.py """ + cdn_server_address = "cdn-951134552678.us-central1.run.app:443" diff --git a/labellerr/connector.py b/labellerr/connector.py new file mode 100644 index 0000000..fe61b9c --- /dev/null +++ b/labellerr/connector.py @@ -0,0 +1,96 @@ +import json +import logging +import uuid + +from labellerr import LabellerrError, constants + + +def _setup_cloud_connector(self, connector_type, client_id, connector_config): + """ + Sets up cloud connector (GCP/AWS) for dataset creation. + + :param connector_type: Type of connector ('gcp' or 'aws') + :param client_id: Client ID + :param connector_config: Configuration dictionary for the connector + :return: Connection ID for the cloud connector + """ + try: + if connector_type == "gcp": + return self._setup_gcp_connector(client_id, connector_config) + elif connector_type == "aws": + return self._setup_aws_connector(client_id, connector_config) + else: + raise LabellerrError(f"Unsupported connector type: {connector_type}") + except Exception as e: + logging.error(f"Failed to setup {connector_type} connector: {e}") + raise + + +def _setup_gcp_connector(self, client_id, gcp_config): + """ + Sets up GCP connector for dataset creation. + + :param client_id: Client ID + :param gcp_config: GCP configuration containing bucket_name, folder_path, credentials + :return: Connection ID for GCP connector + """ + required_fields = ["bucket_name"] + for field in required_fields: + if field not in gcp_config: + raise LabellerrError(f"Required field '{field}' missing in gcp_config") + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/connectors/connect/gcp?client_id={client_id}&uuid={unique_id}" + headers = self._build_headers( + client_id=client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps( + { + "bucket_name": gcp_config["bucket_name"], + "folder_path": gcp_config.get("folder_path", ""), + "service_account_key": gcp_config.get("service_account_key"), + } + ) + + response_data = self._request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + return response_data["response"]["connection_id"] + + +def _setup_aws_connector(self, client_id, aws_config): + """ + Sets up AWS S3 connector for dataset creation. + + :param client_id: Client ID + :param aws_config: AWS configuration containing bucket_name, folder_path, credentials + :return: Connection ID for AWS connector + """ + required_fields = ["bucket_name"] + for field in required_fields: + if field not in aws_config: + raise LabellerrError(f"Required field '{field}' missing in aws_config") + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/connectors/connect/aws?client_id={client_id}&uuid={unique_id}" + headers = self._build_headers( + client_id=client_id, + extra_headers={"content-type": "application/json"}, + ) + + payload = json.dumps( + { + "bucket_name": aws_config["bucket_name"], + "folder_path": aws_config.get("folder_path", ""), + "access_key_id": aws_config.get("access_key_id"), + "secret_access_key": aws_config.get("secret_access_key"), + "region": aws_config.get("region", "us-east-1"), + } + ) + + response_data = self._request( + "POST", url, headers=headers, data=payload, request_id=unique_id + ) + return response_data["response"]["connection_id"] diff --git a/labellerr/core/__init__.py b/labellerr/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/labellerr/core/autolabel/__init__.py b/labellerr/core/autolabel/__init__.py index d9fbeae..11b87ad 100644 --- a/labellerr/core/autolabel/__init__.py +++ b/labellerr/core/autolabel/__init__.py @@ -1,2 +1,2 @@ """Inference core wrappers go here. -""" \ No newline at end of file +""" diff --git a/labellerr/core/base/connectors.py b/labellerr/core/base/connectors.py deleted file mode 100644 index de2d522..0000000 --- a/labellerr/core/base/connectors.py +++ /dev/null @@ -1,5 +0,0 @@ -from abc import ABC, abstractmethod -class BaseConnector(ABC): - @abstractmethod - def connect(self): - raise NotImplementedError \ No newline at end of file diff --git a/labellerr/core/connectors/__init__.py b/labellerr/core/connectors/__init__.py index ceff229..b445782 100644 --- a/labellerr/core/connectors/__init__.py +++ b/labellerr/core/connectors/__init__.py @@ -1,6 +1,7 @@ """ -This module will contain all connectors for the SDK. Example, GCSConnector, S3Connector, etc. +This module will contain all connectors for the SDK. +Example, GCSConnector, S3Connector, etc. Create separate files for each connector. We can manage the connections also in this module. -""" \ No newline at end of file +""" diff --git a/labellerr/core/connectors/gcs.py b/labellerr/core/connectors/gcs.py deleted file mode 100644 index 8dd1915..0000000 --- a/labellerr/core/connectors/gcs.py +++ /dev/null @@ -1,5 +0,0 @@ -from labellerr.core.base.connectors import BaseConnector - -class GCSConnector(BaseConnector): - def connect(self): - pass \ No newline at end of file diff --git a/labellerr/core/datasets/__init__.py b/labellerr/core/datasets/__init__.py index c9f2cce..6b7b5b1 100644 --- a/labellerr/core/datasets/__init__.py +++ b/labellerr/core/datasets/__init__.py @@ -1,2 +1,2 @@ """This module will contain all CRUD for datasets. Example, create, list datasets, get dataset, delete dataset, update dataset, etc. -""" \ No newline at end of file +""" diff --git a/labellerr/core/datasets/datasets.py b/labellerr/core/datasets/datasets.py new file mode 100644 index 0000000..6073033 --- /dev/null +++ b/labellerr/core/datasets/datasets.py @@ -0,0 +1,765 @@ +import json +import logging +import os +import uuid +from asyncio import as_completed +from concurrent.futures import ThreadPoolExecutor + +import requests + +from labellerr import client_utils, gcs, schemas, utils +from labellerr.core import constants +from labellerr.exceptions import LabellerrError +from labellerr.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) diff --git a/labellerr/core/exceptions/__init__.py b/labellerr/core/exceptions/__init__.py index 1bc7c99..fbf590f 100644 --- a/labellerr/core/exceptions/__init__.py +++ b/labellerr/core/exceptions/__init__.py @@ -1,9 +1,9 @@ - """ This module will contain all exceptions for the SDK. We need to define exceptions for Authentication, DataValidation, Support, Rate limits etc. """ + class LabellerrError(Exception): """Custom exception for Labellerr SDK errors.""" diff --git a/labellerr/core/projects/__init__.py b/labellerr/core/projects/__init__.py index bf4e301..4600f76 100644 --- a/labellerr/core/projects/__init__.py +++ b/labellerr/core/projects/__init__.py @@ -1,3 +1,3 @@ """ This module will contain all CRUD for projects. Example, create, list projects, get project, delete project, update project, etc. -""" \ No newline at end of file +""" diff --git a/labellerr/core/typings/__init__.py b/labellerr/core/typings/__init__.py index 4671056..371aa45 100644 --- a/labellerr/core/typings/__init__.py +++ b/labellerr/core/typings/__init__.py @@ -1,4 +1,6 @@ """ -This module will contain all typings for the SDK. Example, Dataset, Project, User, etc. Data definitions for static and dynamic validations go here. +This module will contain all typings for the SDK. +Example, Dataset, Project, User, etc. +Data definitions for static and dynamic validations go here. Use python's pydantic library to define the typings. -""" \ No newline at end of file +""" diff --git a/labellerr/core/users/__init__.py b/labellerr/core/users/__init__.py index 7f6aab5..02e891c 100644 --- a/labellerr/core/users/__init__.py +++ b/labellerr/core/users/__init__.py @@ -1,3 +1,3 @@ """ This module will contain all CRUD for users. Example, create, list users, get user, delete user, update user, etc. -""" \ No newline at end of file +""" diff --git a/labellerr/core/utils/__init__.py b/labellerr/core/utils/__init__.py index c4d7256..e15954e 100644 --- a/labellerr/core/utils/__init__.py +++ b/labellerr/core/utils/__init__.py @@ -1,3 +1,3 @@ """ This module will contain all utils which will be common across modules in the core module only. -""" \ No newline at end of file +""" diff --git a/labellerr/core/validators/__init__.py b/labellerr/core/validators/__init__.py index 8e91aad..b9845ff 100644 --- a/labellerr/core/validators/__init__.py +++ b/labellerr/core/validators/__init__.py @@ -1,5 +1,7 @@ """ -This module will contain all validators for the SDK. Example - validations like incorrect email format, incorrect data type, etc. -Invalid values that should not be accepted by the API go here. Example - local upload file size limit, etc. +This module will contain all validators for the SDK. +Example - validations like incorrect email format, incorrect data type, etc. +Invalid values that should not be accepted by the API go here. +Example - local upload file size limit, etc. These validations should be only handle those which can't be captured by the typings. -""" \ No newline at end of file +""" diff --git a/labellerr/schemas.py b/labellerr/schemas.py new file mode 100644 index 0000000..a09d073 --- /dev/null +++ b/labellerr/schemas.py @@ -0,0 +1,341 @@ +""" +Pydantic models for LabellerrClient method parameter validation. +""" + +import os +from typing import Any, Dict, List, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class NonEmptyStr(str): + """Custom string type that cannot be empty or whitespace-only.""" + + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not isinstance(v, str): + raise ValueError("must be a string") + if not v.strip(): + raise ValueError("must be a non-empty string") + return v + + +class FilePathStr(str): + """File path that must exist and be a valid file.""" + + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not isinstance(v, str): + raise ValueError("must be a string") + if not os.path.exists(v): + raise ValueError(f"file does not exist: {v}") + if not os.path.isfile(v): + raise ValueError(f"path is not a file: {v}") + return v + + +class DirPathStr(str): + """Directory path that must exist and be accessible.""" + + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not isinstance(v, str): + raise ValueError("must be a string") + if not os.path.exists(v): + raise ValueError(f"folder path does not exist: {v}") + if not os.path.isdir(v): + raise ValueError(f"path is not a directory: {v}") + if not os.access(v, os.R_OK): + raise ValueError(f"no read permission for folder: {v}") + return v + + +class RotationConfig(BaseModel): + """Rotation configuration model.""" + + annotation_rotation_count: int = Field(ge=1) + review_rotation_count: int = Field(ge=1) + client_review_rotation_count: int = Field(ge=1) + + +class Question(BaseModel): + """Question structure for annotation templates.""" + + option_type: Literal[ + "input", + "radio", + "boolean", + "select", + "dropdown", + "stt", + "imc", + "BoundingBox", + "polygon", + "dot", + "audio", + ] + # Additional fields can be added as needed + + +class AWSConnectionParams(BaseModel): + """Parameters for creating an AWS S3 connection.""" + + client_id: str = Field(min_length=1) + aws_access_key: str = Field(min_length=1) + aws_secrets_key: str = Field(min_length=1) + s3_path: str = Field(min_length=1) + data_type: Literal["image", "video", "audio", "document", "text"] + name: str = Field(min_length=1) + description: str + connection_type: str = "import" + + +class GCSConnectionParams(BaseModel): + """Parameters for creating a GCS connection.""" + + client_id: str = Field(min_length=1) + gcs_cred_file: str + gcs_path: str = Field(min_length=1) + data_type: Literal["image", "video", "audio", "document", "text"] + name: str = Field(min_length=1) + description: str + connection_type: str = "import" + credentials: str = "svc_account_json" + + @field_validator("gcs_cred_file") + @classmethod + def validate_gcs_cred_file(cls, v): + if not os.path.exists(v): + raise ValueError(f"GCS credential file not found: {v}") + return v + + +class DeleteConnectionParams(BaseModel): + """Parameters for deleting a connection.""" + + client_id: str = Field(min_length=1) + connection_id: str = Field(min_length=1) + + +class UploadFilesParams(BaseModel): + """Parameters for uploading files.""" + + client_id: str = Field(min_length=1) + files_list: List[str] = Field(min_length=1) + + @field_validator("files_list", mode="before") + @classmethod + def validate_files_list(cls, v): + # Convert comma-separated string to list + if isinstance(v, str): + v = v.split(",") + elif not isinstance(v, list): + raise ValueError("must be either a list or a comma-separated string") + + if len(v) == 0: + raise ValueError("no files to upload") + + # Validate each file exists + for file_path in v: + if not os.path.exists(file_path): + raise ValueError(f"file does not exist: {file_path}") + if not os.path.isfile(file_path): + raise ValueError(f"path is not a file: {file_path}") + + return v + + +class DeleteDatasetParams(BaseModel): + """Parameters for deleting a dataset.""" + + client_id: str = Field(min_length=1) + dataset_id: UUID + + +class EnableMultimodalIndexingParams(BaseModel): + """Parameters for enabling multimodal indexing.""" + + client_id: str = Field(min_length=1) + dataset_id: UUID + is_multimodal: bool = True + + +class GetMultimodalIndexingStatusParams(BaseModel): + """Parameters for getting multimodal indexing status.""" + + client_id: str = Field(min_length=1) + dataset_id: UUID + + +class AttachDatasetParams(BaseModel): + """Parameters for attaching a dataset to a project.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) # Accept both UUID and string formats + dataset_id: UUID + + +class DetachDatasetParams(BaseModel): + """Parameters for detaching a dataset from a project.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) # Accept both UUID and string formats + dataset_id: UUID + + +class GetAllDatasetParams(BaseModel): + """Parameters for getting all datasets.""" + + client_id: str = Field(min_length=1) + datatype: str = Field(min_length=1) + project_id: str = Field(min_length=1) + scope: Literal["project", "client", "public"] + + +class CreateLocalExportParams(BaseModel): + """Parameters for creating a local export.""" + + project_id: str = Field(min_length=1) + client_id: str = Field(min_length=1) + export_config: Dict[str, Any] + + +class CreateProjectParams(BaseModel): + """Parameters for creating a project.""" + + project_name: str = Field(min_length=1) + data_type: Literal["image", "video", "audio", "document", "text"] + client_id: str = Field(min_length=1) + attached_datasets: List[str] = Field(min_length=1) + annotation_template_id: str + rotations: RotationConfig + use_ai: bool = False + created_by: Optional[str] = None + + @field_validator("attached_datasets") + @classmethod + def validate_attached_datasets(cls, v): + if not v: + raise ValueError("must contain at least one dataset ID") + for i, dataset_id in enumerate(v): + if not isinstance(dataset_id, str) or not dataset_id.strip(): + raise ValueError(f"dataset_id at index {i} must be a non-empty string") + return v + + +class CreateTemplateParams(BaseModel): + """Parameters for creating an annotation template.""" + + client_id: str = Field(min_length=1) + data_type: Literal["image", "video", "audio", "document", "text"] + template_name: str = Field(min_length=1) + questions: List[Question] = Field(min_length=1) + + +class CreateUserParams(BaseModel): + """Parameters for creating a user.""" + + client_id: str = Field(min_length=1) + first_name: str = Field(min_length=1) + last_name: str = Field(min_length=1) + email_id: str = Field(min_length=1) + projects: List[str] = Field(min_length=1) + roles: List[Dict[str, Any]] = Field(min_length=1) + work_phone: str = "" + job_title: str = "" + language: str = "en" + timezone: str = "GMT" + + +class UpdateUserRoleParams(BaseModel): + """Parameters for updating a user's role.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + email_id: str = Field(min_length=1) + roles: List[Dict[str, Any]] = Field(min_length=1) + first_name: Optional[str] = None + last_name: Optional[str] = None + work_phone: str = "" + job_title: str = "" + language: str = "en" + timezone: str = "GMT" + profile_image: str = "" + + +class DeleteUserParams(BaseModel): + """Parameters for deleting a user.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + email_id: str = Field(min_length=1) + user_id: str = Field(min_length=1) + first_name: Optional[str] = None + last_name: Optional[str] = None + is_active: int = 1 + role: str = "Annotator" + user_created_at: Optional[str] = None + max_activity_created_at: Optional[str] = None + image_url: str = "" + name: Optional[str] = None + activity: str = "No Activity" + creation_date: Optional[str] = None + status: str = "Activated" + + +class AddUserToProjectParams(BaseModel): + """Parameters for adding a user to a project.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + email_id: str = Field(min_length=1) + role_id: Optional[str] = None + + +class RemoveUserFromProjectParams(BaseModel): + """Parameters for removing a user from a project.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + email_id: str = Field(min_length=1) + + +class ChangeUserRoleParams(BaseModel): + """Parameters for changing a user's role.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + email_id: str = Field(min_length=1) + new_role_id: str = Field(min_length=1) + + +class ListFileParams(BaseModel): + """Parameters for listing files.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + search_queries: Dict[str, Any] + size: int = 10 + next_search_after: Optional[Any] = None + + +class BulkAssignFilesParams(BaseModel): + """Parameters for bulk assigning files.""" + + client_id: str = Field(min_length=1) + project_id: str = Field(min_length=1) + file_ids: List[str] = Field(min_length=1) + new_status: str = Field(min_length=1) diff --git a/labellerr/services/autolabel/__init__.py b/labellerr/services/autolabel/__init__.py index 2195b02..3cad203 100644 --- a/labellerr/services/autolabel/__init__.py +++ b/labellerr/services/autolabel/__init__.py @@ -1,2 +1,2 @@ """This module will have API handling for triggering SAM, SAM2 jobs. -""" \ No newline at end of file +""" diff --git a/labellerr/services/video_sampling/__init__.py b/labellerr/services/video_sampling/__init__.py index 8c37956..2fb5def 100644 --- a/labellerr/services/video_sampling/__init__.py +++ b/labellerr/services/video_sampling/__init__.py @@ -1,4 +1,4 @@ """All the code for video sampling will go here. All algorithms for video sampling will go in separate files. -""" \ No newline at end of file +""" diff --git a/labellerr/utils.py b/labellerr/utils.py index d7e3431..3d1727d 100644 --- a/labellerr/utils.py +++ b/labellerr/utils.py @@ -1,5 +1,6 @@ import logging import time +from functools import wraps from typing import Any, Callable, Optional, TypeVar, Union T = TypeVar("T") @@ -96,3 +97,44 @@ def poll( # Wait before next attempt time.sleep(interval) + + +def validate_params(**validations): + """ + Decorator to validate method parameters based on type specifications. + + Usage: + @validate_params(project_id=str, file_id=str, keyFrames=list) + def some_method(self, project_id, file_id, keyFrames): + ... + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + + # Validate each parameter + for param_name, expected_type in validations.items(): + if param_name in bound.arguments: + value = bound.arguments[param_name] + if not isinstance(value, expected_type): + from .exceptions import LabellerrError + + type_name = ( + " or ".join(t.__name__ for t in expected_type) + if isinstance(expected_type, tuple) + else expected_type.__name__ + ) + raise LabellerrError(f"{param_name} must be a {type_name}") + + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/labellerr/validators.py b/labellerr/validators.py new file mode 100644 index 0000000..de5b8c2 --- /dev/null +++ b/labellerr/validators.py @@ -0,0 +1,925 @@ +""" +Validation decorators for LabellerrClient methods +""" + +import functools +import logging +from typing import Callable, List + +from . import constants +from .exceptions import LabellerrError + + +def validate_required(params: List[str]): + """ + Decorator to validate required parameters are present and not None/empty. + + :param params: List of parameter names that are required + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + # Check required parameters + for param in params: + if param not in bound_args.arguments: + raise LabellerrError(f"Required parameter {param} is missing") + + value = bound_args.arguments[param] + if value is None or (isinstance(value, str) and not value.strip()): + raise LabellerrError( + f"Required parameter {param} cannot be null or empty" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_data_type(param_name: str = "data_type"): + """ + Decorator to validate data_type parameter against allowed types. + + :param param_name: Name of the parameter to validate (default: 'data_type') + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + data_type = bound_args.arguments[param_name] + if data_type not in constants.DATA_TYPES: + raise LabellerrError( + f"Invalid data_type. Must be one of {constants.DATA_TYPES}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_list_not_empty(param_name: str): + """ + Decorator to validate that a parameter is a non-empty list. + + :param param_name: Name of the parameter to validate + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + value = bound_args.arguments[param_name] + if not isinstance(value, list): + raise LabellerrError(f"{param_name} must be a list") + if len(value) == 0: + raise LabellerrError(f"{param_name} must be a non-empty list") + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_client_id(param_name: str = "client_id"): + """ + Decorator to validate client_id parameter. + + :param param_name: Name of the parameter to validate (default: 'client_id') + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + client_id = bound_args.arguments[param_name] + if not isinstance(client_id, str): + raise LabellerrError(f"{param_name} must be a string") + if not client_id.strip(): + raise LabellerrError(f"{param_name} must be a non-empty string") + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_questions_structure(): + """ + Decorator to validate questions structure for template creation. + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if "questions" in bound_args.arguments: + questions = bound_args.arguments["questions"] + for i, question in enumerate(questions): + if not isinstance(question, dict): + raise LabellerrError(f"Question {i+1} must be a dictionary") + + if "option_type" not in question: + raise LabellerrError(f"Question {i+1}: option_type is required") + + if question["option_type"] not in constants.OPTION_TYPE_LIST: + raise LabellerrError( + f"Question {i+1}: option_type must be one of {constants.OPTION_TYPE_LIST}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def log_method_call(include_params: bool = True): + """ + Decorator to log method calls for debugging purposes. + + :param include_params: Whether to include parameter values in logs + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + method_name = func.__name__ + if include_params: + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + # Filter out 'self' and sensitive parameters + filtered_params = { + k: v + for k, v in bound_args.arguments.items() + if k != "self" + and "secret" not in k.lower() + and "key" not in k.lower() + } + logging.debug(f"Calling {method_name} with params: {filtered_params}") + else: + logging.debug(f"Calling {method_name}") + + try: + result = func(self, *args, **kwargs) + logging.debug(f"{method_name} completed successfully") + return result + except Exception as e: + logging.error(f"{method_name} failed: {str(e)}") + raise + + return wrapper + + return decorator + + +def validate_rotations_structure(param_name: str = "rotations"): + """ + Decorator to validate rotation configuration structure. + + :param param_name: Name of the parameter to validate (default: 'rotations') + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + rotation_config = bound_args.arguments[param_name] + if not isinstance(rotation_config, dict): + raise LabellerrError(f"{param_name} must be a dictionary") + + required_keys = [ + "annotation_rotation_count", + "review_rotation_count", + "client_review_rotation_count", + ] + + for key in required_keys: + if key not in rotation_config: + raise LabellerrError(f"{param_name} must contain '{key}'") + + value = rotation_config[key] + if not isinstance(value, int) or value < 1: + raise LabellerrError( + f"{param_name}.{key} must be a positive integer" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_dataset_ids(param_name: str = "attached_datasets"): + """ + Decorator to validate dataset IDs list. + + :param param_name: Name of the parameter to validate (default: 'attached_datasets') + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + dataset_ids = bound_args.arguments[param_name] + if not isinstance(dataset_ids, list): + raise LabellerrError(f"{param_name} must be a list") + if len(dataset_ids) == 0: + raise LabellerrError( + f"{param_name} must contain at least one dataset ID" + ) + + for i, dataset_id in enumerate(dataset_ids): + if not isinstance(dataset_id, str) or not dataset_id.strip(): + raise LabellerrError( + f"{param_name}[{i}] must be a non-empty string" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_uuid_format(param_name: str): + """ + Decorator to validate UUID format for parameters. + + :param param_name: Name of the parameter to validate + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # Get function signature to map args to parameter names + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + value = bound_args.arguments[param_name] + if value is not None: # Allow None for optional parameters + import uuid as uuid_module + + try: + uuid_module.UUID(str(value)) + except (ValueError, TypeError): + raise LabellerrError( + f"{param_name} must be a valid UUID format" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_string_type(param_name: str, allow_empty: bool = False): + """ + Decorator to validate that a parameter is a string and optionally non-empty. + + :param param_name: Name of the parameter to validate + :param allow_empty: Whether empty strings are allowed (default: False) + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + value = bound_args.arguments[param_name] + if value is not None: # Allow None for optional parameters + if not isinstance(value, str): + raise LabellerrError(f"{param_name} must be a string") + if not allow_empty and not value.strip(): + raise LabellerrError(f"{param_name} must be a non-empty string") + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_not_none(param_names: List[str]): + """ + Decorator to validate that parameters are not None. + + :param param_names: List of parameter names that cannot be None + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + for param_name in param_names: + if param_name in bound_args.arguments: + value = bound_args.arguments[param_name] + if value is None: + raise LabellerrError(f"{param_name} cannot be null") + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_file_exists(param_names: List[str]): + """ + Decorator to validate that file parameters exist. + + :param param_names: List of parameter names that should be valid file paths + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + import os + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + for param_name in param_names: + if param_name in bound_args.arguments: + file_path = bound_args.arguments[param_name] + if file_path is not None: + if not os.path.exists(file_path): + raise LabellerrError(f"File does not exist: {file_path}") + if not os.path.isfile(file_path): + raise LabellerrError(f"Path is not a file: {file_path}") + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_directory_exists(param_names: List[str]): + """ + Decorator to validate that directory parameters exist and are accessible. + + :param param_names: List of parameter names that should be valid directory paths + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + import os + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + for param_name in param_names: + if param_name in bound_args.arguments: + dir_path = bound_args.arguments[param_name] + if dir_path is not None: + if not os.path.exists(dir_path): + raise LabellerrError( + f"Folder path does not exist: {dir_path}" + ) + if not os.path.isdir(dir_path): + raise LabellerrError(f"Path is not a directory: {dir_path}") + if not os.access(dir_path, os.R_OK): + raise LabellerrError( + f"No read permission for folder: {dir_path}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_file_list_or_string(param_names: List[str]): + """ + Decorator to validate file list parameters (can be list or comma-separated string). + + :param param_names: List of parameter names that should be file lists + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + import os + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + for param_name in param_names: + if param_name in bound_args.arguments: + files_list = bound_args.arguments[param_name] + if files_list is not None: + # Convert string to list if necessary + if isinstance(files_list, str): + files_list = files_list.split(",") + # Update the bound args for the actual function + bound_args.arguments[param_name] = files_list + elif not isinstance(files_list, list): + raise LabellerrError( + f"{param_name} must be either a list or a comma-separated string" + ) + + if len(files_list) == 0: + raise LabellerrError(f"No files to upload in {param_name}") + + # Validate each file exists + for file_path in files_list: + if not os.path.exists(file_path): + raise LabellerrError( + f"File does not exist: {file_path}" + ) + if not os.path.isfile(file_path): + raise LabellerrError(f"Path is not a file: {file_path}") + + # Update kwargs with potentially modified arguments + for k, v in bound_args.arguments.items(): + if k != "self" and k in sig.parameters: + idx = list(sig.parameters.keys()).index(k) - 1 # -1 for self + if idx < len(args): + args = list(args) + args[idx] = v + else: + kwargs[k] = v + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_annotation_format( + param_name: str = "annotation_format", file_param: str = None +): + """ + Decorator to validate annotation format and optionally check file extension compatibility. + + :param param_name: Name of the annotation format parameter + :param file_param: Optional name of the file parameter to check extension compatibility + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + import os + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + annotation_format = bound_args.arguments[param_name] + if annotation_format is not None: + if annotation_format not in constants.ANNOTATION_FORMAT: + raise LabellerrError( + f"Invalid annotation_format. Must be one of {constants.ANNOTATION_FORMAT}" + ) + + # Check file extension compatibility if file parameter is provided + if file_param and file_param in bound_args.arguments: + annotation_file = bound_args.arguments[file_param] + if ( + annotation_file is not None + and 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" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_export_format(param_name: str = "export_format"): + """ + Decorator to validate export format against allowed formats. + + :param param_name: Name of the parameter to validate + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + export_format = bound_args.arguments[param_name] + if export_format is not None: + if export_format not in constants.LOCAL_EXPORT_FORMAT: + raise LabellerrError( + f"Invalid export_format. Must be one of {constants.LOCAL_EXPORT_FORMAT}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_export_statuses(param_name: str = "statuses"): + """ + Decorator to validate export statuses list. + + :param param_name: Name of the parameter to validate + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + statuses = bound_args.arguments[param_name] + if statuses is not None: + if not isinstance(statuses, list): + raise LabellerrError(f"Invalid {param_name}. Must be an array") + for status in statuses: + if status not in constants.LOCAL_EXPORT_STATUS: + raise LabellerrError( + f"Invalid status. Must be one of {constants.LOCAL_EXPORT_STATUS}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_scope(param_name: str = "scope"): + """ + Decorator to validate scope parameter against allowed scopes. + + :param param_name: Name of the parameter to validate + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + if param_name in bound_args.arguments: + scope = bound_args.arguments[param_name] + if scope is not None: + if scope not in constants.SCOPE_LIST: + raise LabellerrError( + f"scope must be one of {', '.join(constants.SCOPE_LIST)}" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_upload_method_exclusive( + file_param: str = "files_to_upload", folder_param: str = "folder_to_upload" +): + """ + Decorator to validate that only one upload method is specified. + + :param file_param: Name of the files parameter + :param folder_param: Name of the folder parameter + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + has_files = ( + file_param in bound_args.arguments + and bound_args.arguments[file_param] is not None + ) + has_folder = ( + folder_param in bound_args.arguments + and bound_args.arguments[folder_param] is not None + ) + + if has_files and has_folder: + raise LabellerrError( + f"Cannot provide both {file_param} and {folder_param}" + ) + + if not has_files and not has_folder: + raise LabellerrError( + f"Either {file_param} or {folder_param} must be provided" + ) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_file_limits(total_count_limit: int = None, total_size_limit: int = None): + """ + Decorator to validate file count and size limits. + + :param total_count_limit: Maximum number of files allowed + :param total_size_limit: Maximum total size in bytes + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + # This decorator is more complex as it needs to work with method-specific logic + # For now, we'll delegate to the method to perform the actual counting + # The validation will be done within the method itself + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_business_logic_rotation_config(): + """ + Decorator to validate rotation config business rules. + This uses the existing client_utils validation. + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + # Look for rotation config in various possible parameter names + rotation_config = None + for param_name in ["rotation_config", "rotations"]: + if param_name in bound_args.arguments: + rotation_config = bound_args.arguments[param_name] + break + + if rotation_config is not None: + from . import client_utils + + client_utils.validate_rotation_config(rotation_config) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def handle_api_errors(func: Callable) -> Callable: + """ + Decorator to standardize API error handling. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except LabellerrError: + # Re-raise LabellerrError as-is + raise + except Exception as e: + method_name = func.__name__ + logging.error(f"Unexpected error in {method_name}: {e}") + raise + + return wrapper + + +def auto_log_and_handle_errors( + include_params: bool = False, exclude_methods: List[str] = None +): + """ + Class decorator that automatically applies logging and error handling to all public methods. + + :param include_params: Whether to include parameters in log messages (default: False) + :param exclude_methods: List of method names to exclude from auto-decoration + """ + if exclude_methods is None: + exclude_methods = [] + + def class_decorator(cls): + import inspect + + # Get all methods in the class + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + # Skip private methods, dunder methods, and excluded methods + if name.startswith("_") or name in exclude_methods: + continue + + # Check if method already has decorators we want to apply + has_log_decorator = hasattr(method, "__wrapped__") + has_error_decorator = hasattr(method, "__wrapped__") + + # Apply decorators if not already present + if not has_log_decorator and not has_error_decorator: + # Apply both decorators: error handling first, then logging + decorated_method = log_method_call(include_params=include_params)( + method + ) + decorated_method = handle_api_errors(decorated_method) + setattr(cls, name, decorated_method) + + return cls + + return class_decorator + + +def auto_log_and_handle_errors_async( + include_params: bool = False, exclude_methods: List[str] = None +): + """ + Class decorator that automatically applies logging and error handling to all public async methods. + + :param include_params: Whether to include parameters in log messages (default: False) + :param exclude_methods: List of method names to exclude from auto-decoration + """ + if exclude_methods is None: + exclude_methods = [] + + def async_log_decorator(include_params: bool = True): + """Async version of log_method_call decorator.""" + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(self, *args, **kwargs): + method_name = func.__name__ + if include_params: + import inspect + + sig = inspect.signature(func) + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + filtered_params = { + k: v + for k, v in bound_args.arguments.items() + if k != "self" + and "secret" not in k.lower() + and "key" not in k.lower() + } + logging.debug( + f"Calling {method_name} with params: {filtered_params}" + ) + else: + logging.debug(f"Calling {method_name}") + + try: + result = await func(self, *args, **kwargs) + logging.debug(f"{method_name} completed successfully") + return result + except Exception as e: + logging.error(f"{method_name} failed: {str(e)}") + raise + + return wrapper + + return decorator + + def async_error_handler(func: Callable) -> Callable: + """Async version of handle_api_errors decorator.""" + + @functools.wraps(func) + async def wrapper(self, *args, **kwargs): + try: + return await func(self, *args, **kwargs) + except LabellerrError: + raise + except Exception as e: + method_name = func.__name__ + logging.error(f"Unexpected error in {method_name}: {e}") + raise + + return wrapper + + def class_decorator(cls): + import inspect + + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + if name.startswith("_") or name in exclude_methods: + continue + + # Only apply to async methods + if inspect.iscoroutinefunction(method): + has_decorators = hasattr(method, "__wrapped__") + if not has_decorators: + decorated_method = async_log_decorator( + include_params=include_params + )(method) + decorated_method = async_error_handler(decorated_method) + setattr(cls, name, decorated_method) + + return cls + + return class_decorator diff --git a/labellerr_integration_case_tests.py b/labellerr_integration_case_tests.py new file mode 100644 index 0000000..6dc0ede --- /dev/null +++ b/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.client import LabellerrClient +from labellerr.exceptions import LabellerrError + +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/labellerr_use_case_tests.py b/labellerr_use_case_tests.py deleted file mode 100644 index 901fd4b..0000000 --- a/labellerr_use_case_tests.py +++ /dev/null @@ -1,547 +0,0 @@ -import os -import sys -import time -import json -import tempfile -import unittest -from unittest.mock import patch -from labellerr.client import LabellerrClient -from labellerr.exceptions import LabellerrError -import dotenv - -dotenv.load_dotenv() - - -class LabelerUseCaseIntegrationTests(unittest.TestCase): - - def setUp(self): - - self.api_key = os.getenv("API_KEY", "test-api-key") - self.api_secret = os.getenv("API_SECRET", "test-api-secret") - self.client_id = os.getenv("CLIENT_ID", "test-client-id") - self.test_email = os.getenv("CLIENT_EMAIL", "test@example.com") - - if ( - self.api_key == "test-api-key" - or self.api_secret == "test-api-secret" - or self.client_id == "test-client-id" - or self.test_email == "test@example.com" - ): - - raise ValueError( - "Real Labellerr credentials are required for integration testing. " - "Please set environment variables: " - "LABELLERR_API_KEY, LABELLERR_API_SECRET, LABELLERR_CLIENT_ID, LABELLERR_TEST_EMAIL" - ) - - # Initialize the client - self.client = LabellerrClient(self.api_key, self.api_secret) - - # Common test data - 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"], - }, - ] - - # Valid rotation configuration - self.rotation_config = { - "annotation_rotation_count": 1, - "review_rotation_count": 1, - "client_review_rotation_count": 1, - } - - def test_use_case_1_complete_project_creation_workflow(self): - - # Create temporary test files to simulate real data upload - test_files = [] - try: - # Create sample image files for testing - 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") - - # Store project details for potential cleanup - 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: - # Clean up temporary files - for file_path in test_files: - try: - os.unlink(file_path) - except OSError: - pass - - def test_use_case_1_validation_requirements(self): - """Table-driven test for project creation validation requirements""" - - validation_test_cases = [ - { - "test_name": "Missing client_id", - "payload_overrides": {"client_id": None}, - "remove_keys": ["client_id"], - "expected_error": "Required parameter client_id is missing", - }, - { - "test_name": "Invalid email format", - "payload_overrides": {"created_by": "invalid-email"}, - "remove_keys": [], - "expected_error": "Please enter email id in created_by", - }, - { - "test_name": "Invalid data type", - "payload_overrides": {"data_type": "invalid_type"}, - "remove_keys": [], - "expected_error": "Invalid data_type", - }, - { - "test_name": "Missing dataset_name", - "payload_overrides": {}, - "remove_keys": ["dataset_name"], - "expected_error": "Required parameter dataset_name is missing", - }, - { - "test_name": "Missing annotation guide and template ID", - "payload_overrides": {}, - "remove_keys": ["annotation_guide"], - "expected_error": "Please provide either annotation guide or annotation template id", - }, - ] - - # Base valid payload - 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": [], - "annotation_guide": self.annotation_guide, - } - - for i, test_case in enumerate(validation_test_cases, 1): - with self.subTest(test_name=test_case["test_name"]): - - # Create test payload by modifying base payload - test_payload = base_payload.copy() - test_payload.update(test_case["payload_overrides"]) - - # Remove keys if specified - for key in test_case["remove_keys"]: - test_payload.pop(key, None) - - # Execute test and verify expected error - with self.assertRaises(LabellerrError) as context: - self.client.initiate_create_project(test_payload) - - # Verify error message contains expected substring - error_message = str(context.exception) - self.assertIn( - test_case["expected_error"], - error_message, - f"Expected error '{test_case['expected_error']}' not found in '{error_message}'", - ) - - def test_use_case_1_multiple_data_types_table_driven(self): - - project_test_scenarios = [ - { - "scenario_name": "Image Classification Project", - "data_type": "image", - "file_extensions": [".jpg", ".png"], - "annotation_types": ["select", "radio"], - "expected_success": True, - }, - { - "scenario_name": "Document Processing Project", - "data_type": "document", - "file_extensions": [".pdf"], - "annotation_types": ["input", "boolean"], - "expected_success": True, - }, - ] - - test_scenario = project_test_scenarios[0] # Image classification - - test_files = [] - try: - for ext in test_scenario["file_extensions"][:2]: # Limit to 2 files - temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False) - temp_file.write(f'fake_{test_scenario["data_type"]}_data'.encode()) - temp_file.close() - test_files.append(temp_file.name) - - annotation_guide = [] - for i, annotation_type in enumerate(test_scenario["annotation_types"]): - annotation_guide.append( - { - "question": f"Test question {i+1}", - "option_type": annotation_type, - "options": ( - ["option1", "option2", "option3"] - if annotation_type in ["select", "radio"] - else [] - ), - } - ) - - # Build project payload - project_payload = { - "client_id": self.client_id, - "dataset_name": f"SDK_Test_{test_scenario['data_type']}_{int(time.time())}", - "dataset_description": f"Test dataset for {test_scenario['scenario_name']}", - "data_type": test_scenario["data_type"], - "created_by": self.test_email, - "project_name": f"SDK_Test_Project_{test_scenario['data_type']}_{int(time.time())}", - "autolabel": False, - "files_to_upload": test_files, - "annotation_guide": annotation_guide, - "rotation_config": self.rotation_config, - } - - # Execute test based on credentials - result = self.client.initiate_create_project(project_payload) - - self.assertIsInstance(result, dict) - self.assertEqual(result.get("status"), "success") - print(f"✓ {test_scenario['scenario_name']} project created successfully") - - finally: - # Clean up test files - for file_path in test_files: - try: - os.unlink(file_path) - except OSError: - pass - - def test_use_case_2_preannotation_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 = "test-project-id" - 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 - - print( - "Calling actual Labellerr pre-annotation API with real credentials..." - ) - - try: - with patch.object( - self.client, "preannotation_job_status", create=True - ) as mock_status: - mock_status.return_value = { - "response": {"status": "completed", "job_id": "real-job-id"} - } - - 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_use_case_2_format_validation(self): - - format_test_cases = [ - { - "test_name": "Invalid annotation format", - "project_id": "test-project", - "annotation_format": "invalid_format", - "annotation_file": "test.json", - "expected_error": "Invalid annotation_format", - "create_temp_file": False, - "temp_suffix": None, - }, - { - "test_name": "File not found", - "project_id": "test-project", - "annotation_format": "json", - "annotation_file": "non_existent_file.json", - "expected_error": "File not found", - "create_temp_file": False, - "temp_suffix": None, - }, - { - "test_name": "Wrong file extension for COCO format", - "project_id": "test-project", - "annotation_format": "coco_json", - "annotation_file": None, # Will be set to temp file - "expected_error": "For coco_json annotation format, the file must have a .json extension", - "create_temp_file": True, - "temp_suffix": ".txt", - }, - ] - - for i, test_case in enumerate(format_test_cases, 1): - with self.subTest(test_name=test_case["test_name"]): - - temp_file = None - try: - # Create temporary file if needed - if test_case["create_temp_file"]: - temp_file = tempfile.NamedTemporaryFile( - suffix=test_case["temp_suffix"], delete=False - ) - temp_file.write(b"test content") - temp_file.close() - annotation_file = temp_file.name - else: - annotation_file = test_case["annotation_file"] - - # Execute test and verify expected error - with self.assertRaises(LabellerrError) as context: - self.client._upload_preannotation_sync( - project_id=test_case["project_id"], - client_id=self.client_id, - annotation_format=test_case["annotation_format"], - annotation_file=annotation_file, - ) - - # Verify error message contains expected substring - error_message = str(context.exception) - self.assertIn( - test_case["expected_error"], - error_message, - f"Expected error '{test_case['expected_error']}' not found in '{error_message}'", - ) - - finally: - # Clean up temporary file - if temp_file: - try: - os.unlink(temp_file.name) - except OSError: - pass - - def test_use_case_2_multiple_formats_table_driven(self): - - preannotation_scenarios = [ - { - "scenario_name": "COCO JSON Upload", - "annotation_format": "coco_json", - "file_extension": ".json", - "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"} - ], - }, - "expected_success": True, - }, - { - "scenario_name": "JSON Annotations Upload", - "annotation_format": "json", - "file_extension": ".json", - "sample_data": { - "labels": [ - { - "image": "test.jpg", - "annotations": [{"label": "cat", "confidence": 0.95}], - } - ] - }, - "expected_success": True, - }, - ] - - test_scenario = preannotation_scenarios[0] # COCO JSON - - temp_annotation_file = None - try: - temp_annotation_file = tempfile.NamedTemporaryFile( - mode="w", suffix=test_scenario["file_extension"], delete=False - ) - json.dump(test_scenario["sample_data"], temp_annotation_file) - temp_annotation_file.close() - - # Use project ID from previous tests if available - test_project_id = getattr( - self, "created_project_id", "test-project-id-table-driven" - ) - - try: - # Only patch the missing method, let everything else be real - with patch.object( - self.client, "preannotation_job_status", create=True - ) as mock_status: - mock_status.return_value = { - "response": { - "status": "completed", - "job_id": f'job-{test_scenario["annotation_format"]}-{int(time.time())}', - } - } - - result = self.client._upload_preannotation_sync( - project_id=test_project_id, - client_id=self.client_id, - annotation_format=test_scenario["annotation_format"], - annotation_file=temp_annotation_file.name, - ) - - self.assertIsInstance(result, dict) - - except Exception as api_error: - raise api_error - - finally: - # Clean up annotation file - if temp_annotation_file: - try: - os.unlink(temp_annotation_file.name) - except OSError: - pass - - def tearDown(self): - pass - - @classmethod - def setUpClass(cls): - """Set up test suite.""" - - @classmethod - def tearDownClass(cls): - """Tear down test suite.""" - - -def run_use_case_tests(): - - # Create test suite - suite = unittest.TestLoader().loadTestsFromTestCase(LabelerUseCaseIntegrationTests) - - # 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__": - """ - Main execution block for running use case integration tests. - - 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 - - Run with: - python use_case_tests.py - """ - # 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)] - - # Run the tests - success = run_use_case_tests() - - # Exit with appropriate code - sys.exit(0 if success else 1) diff --git a/pyproject.toml b/pyproject.toml index da5f0b5..31c9a43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "aiohttp>=3.8.0", "aiofiles>=0.8.0", "certifi>=2021.5.25", + "pydantic>=2.0.0", ] [project.optional-dependencies] @@ -45,6 +46,7 @@ dev = [ "flake8>=3.8.0", "mypy>=0.800", "isort>=5.0.0", + "python-dotenv>=1.0.0", "build>=0.3.0", ] docs = [ @@ -92,19 +94,24 @@ line_length = 88 known_first_party = ["labellerr"] [tool.mypy] -python_version = "3.7" -warn_return_any = true +python_version = "3.8" +files = ["labellerr"] +exclude = "(^tests/|labellerr_integration_case_tests.py$)" +ignore_missing_imports = true +follow_imports = "silent" +allow_redefinition = true +warn_return_any = false warn_unused_configs = true -disallow_untyped_defs = true -disallow_incomplete_defs = true -check_untyped_defs = true -disallow_untyped_decorators = true -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_no_return = true -warn_unreachable = true -strict_equality = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = false +disallow_untyped_decorators = false +no_implicit_optional = false +warn_redundant_casts = false +warn_unused_ignores = false +warn_no_return = false +warn_unreachable = false +strict_equality = false [tool.pytest.ini_options] minversion = "6.0" @@ -124,4 +131,4 @@ exclude_lines = [ "def __repr__", "raise AssertionError", "raise NotImplementedError", -] \ No newline at end of file +] diff --git a/requirements.txt b/requirements.txt index e286d93..da57d0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ +urllib3 python-dotenv requests pytest - +pydantic>=2.0.0 diff --git a/tests/integration/.gitignore b/tests/integration/.gitignore index ce42ff4..b3330b5 100644 --- a/tests/integration/.gitignore +++ b/tests/integration/.gitignore @@ -1,3 +1,3 @@ __pychache__ .env -.venv \ No newline at end of file +.venv diff --git a/tests/integration/Create_Project.py b/tests/integration/Create_Project.py index 8a797e2..af94156 100644 --- a/tests/integration/Create_Project.py +++ b/tests/integration/Create_Project.py @@ -1,148 +1,157 @@ -import sys import os -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'SDKPython'))) +import sys + +sys.path.append( + 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__), '..', '..')) +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 -import uuid -def create_project_all_option_type(api_key, api_secret, client_id, email, path_to_images): + +def create_project_all_option_type( + api_key, api_secret, client_id, email, path_to_images +): """Creates a project with all option types using the Labellerr SDK.""" - + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'A sample dataset for image classification', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'Testing_project-7', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "A sample dataset for image classification", + "data_type": "image", + "created_by": email, + "project_name": "Testing_project-7", + "annotation_guide": [ { - "question_number": 1, # incremental series starting from 1 - "question": "Test", # question name - "question_id": "533bb0c8-fb2b-4394-a8e1-5042a944802f", # random uuid + "question_number": 1, # incremental series starting from 1 + "question": "Test", # question name + "question_id": "533bb0c8-fb2b-4394-a8e1-5042a944802f", # random uuid "option_type": "polygon", "required": True, "options": [ - {"option_name": "#fe1236"}, # give the hex code of some random color - ] + { + "option_name": "#fe1236" + }, # give the hex code of some random color + ], }, { - "question_number": 2, # Pixel annotation for bounding box format + "question_number": 2, # Pixel annotation for bounding box format "question": "Test2", "question_id": "533bb0c8-fb2b-4394-a8e1-5042a944808d", "option_type": "BoundingBox", "required": True, - "options": [ - {"option_name": "#afe126"} - ] + "options": [{"option_name": "#afe126"}], }, { - "question_number": 3, # Classification question for simple input field - "question": "Test-Input", - "option_type": "input", - "question_id": "81bc5c1a-5b95-4df2-8085-aca8d66a93ad", - "required": True, - "options": [] # this will be empty array only + "question_number": 3, # Classification question for simple input field + "question": "Test-Input", + "option_type": "input", + "question_id": "81bc5c1a-5b95-4df2-8085-aca8d66a93ad", + "required": True, + "options": [], # this will be empty array only }, { - "question_number": 4, # Classification question for multi-select dropdown - "question": "Multi-Test", - "option_type": "select", - "question_id": "971c5c1a-5b95-4df2-8085-aca8d66a0351", - "required": True, - "options": [ + "question_number": 4, # Classification question for multi-select dropdown + "question": "Multi-Test", + "option_type": "select", + "question_id": "971c5c1a-5b95-4df2-8085-aca8d66a0351", + "required": True, + "options": [ { "option_id": "22b7942f-06ef-4293-9d73-d117eda8ec0d", - "option_name": "A" + "option_name": "A", }, { "option_id": "15e0e903-ed8f-43ff-a841-a0638ff08153", - "option_name": "B" + "option_name": "B", }, { "option_id": "c2e37dad-5034-4bed-920b-5fc14c4032e0", - "option_name": "C" - } - ] + "option_name": "C", + }, + ], }, { - "question_number": 5, # Classification question for single-select dropdown - "question": "Test-Dropdown", - "option_type": "dropdown", - "question_id": "456c5c1a-5b95-4df2-8085-aca8d66a03049", - "required": True, - "options": [ + "question_number": 5, # Classification question for single-select dropdown + "question": "Test-Dropdown", + "option_type": "dropdown", + "question_id": "456c5c1a-5b95-4df2-8085-aca8d66a03049", + "required": True, + "options": [ { "option_id": "58k142f-06ef-4293-9d73-d117eda87254", - "option_name": "Sample A" + "option_name": "Sample A", }, { "option_id": "43t56903-ed8f-43ff-a841-a0638ff08856", - "option_name": "Sample B" - } - ] + "option_name": "Sample B", + }, + ], }, { - "question_number": 6, # Classification question for radio - "question": "Radio test", - "option_type": "radio", - "question_id": "712v5c1a-5b95-4df2-8085-aca8d66a01048", - "required": True, - "options": [ + "question_number": 6, # Classification question for radio + "question": "Radio test", + "option_type": "radio", + "question_id": "712v5c1a-5b95-4df2-8085-aca8d66a01048", + "required": True, + "options": [ { "option_id": "916v24h-06ef-4293-9d73-d117eda81112", - "option_name": "1" + "option_name": "1", }, { "option_id": "12ak879-ed8f-43ff-a841-a0638ff23115", - "option_name": "2" - } - ] - } + "option_name": "2", + }, + ], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[ALL OPTION TYPE] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[ALL OPTION TYPE] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - - -def create_project_polygon_boundingbox_project(api_key, api_secret, client_id, email, path_to_images): + + +def create_project_polygon_boundingbox_project( + api_key, api_secret, client_id, email, path_to_images +): client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Dataset for object detection with polygon and bounding box annotations', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'polygon_boundingbox_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Dataset for object detection with polygon and bounding box annotations", + "data_type": "image", + "created_by": email, + "project_name": "polygon_boundingbox_project", + "annotation_guide": [ { "question_number": 1, "question": "Vehicle Detection", "question_id": str(uuid.uuid4()), "option_type": "polygon", "required": True, - "options": [ - {"option_name": "#ff6b35"} # Orange for vehicles - ] + "options": [{"option_name": "#ff6b35"}], # Orange for vehicles }, { "question_number": 2, @@ -150,38 +159,41 @@ def create_project_polygon_boundingbox_project(api_key, api_secret, client_id, e "question_id": str(uuid.uuid4()), "option_type": "BoundingBox", "required": True, - "options": [ - {"option_name": "#4ecdc4"} # Teal for persons - ] - } + "options": [{"option_name": "#4ecdc4"}], # Teal for persons + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[polygon_boundingbox] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[polygon_boundingbox] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - -def create_project_select_dropdown_radio(api_key, api_secret, client_id, email, path_to_images): - + + +def create_project_select_dropdown_radio( + api_key, api_secret, client_id, email, path_to_images +): + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Dataset for multi-label image classification', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'select_dropdown_radio_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Dataset for multi-label image classification", + "data_type": "image", + "created_by": email, + "project_name": "select_dropdown_radio_project", + "annotation_guide": [ { "question_number": 1, "question": "Object Categories", @@ -189,23 +201,11 @@ def create_project_select_dropdown_radio(api_key, api_secret, client_id, email, "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Animals" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Vehicles" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Buildings" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Nature" - } - ] + {"option_id": str(uuid.uuid4()), "option_name": "Animals"}, + {"option_id": str(uuid.uuid4()), "option_name": "Vehicles"}, + {"option_id": str(uuid.uuid4()), "option_name": "Buildings"}, + {"option_id": str(uuid.uuid4()), "option_name": "Nature"}, + ], }, { "question_number": 2, @@ -214,19 +214,10 @@ def create_project_select_dropdown_radio(api_key, api_secret, client_id, email, "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "High Quality" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Medium Quality" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Low Quality" - } - ] + {"option_id": str(uuid.uuid4()), "option_name": "High Quality"}, + {"option_id": str(uuid.uuid4()), "option_name": "Medium Quality"}, + {"option_id": str(uuid.uuid4()), "option_name": "Low Quality"}, + ], }, { "question_number": 3, @@ -235,66 +226,57 @@ def create_project_select_dropdown_radio(api_key, api_secret, client_id, email, "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Bright" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Dim" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Dark" - } - ] - } + {"option_id": str(uuid.uuid4()), "option_name": "Bright"}, + {"option_id": str(uuid.uuid4()), "option_name": "Dim"}, + {"option_id": str(uuid.uuid4()), "option_name": "Dark"}, + ], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[select_dropdown_radio] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[select_dropdown_radio] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - + + def create_project_polygon_input(api_key, api_secret, client_id, email, path_to_images): - + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Medical images with detailed annotations and metadata', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'polygon_input_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Medical images with detailed annotations and metadata", + "data_type": "image", + "created_by": email, + "project_name": "polygon_input_project", + "annotation_guide": [ { "question_number": 1, "question": "Anomaly Region", "question_id": str(uuid.uuid4()), "option_type": "polygon", "required": True, - "options": [ - {"option_name": "#ff4757"} # Red for anomalies - ] + "options": [{"option_name": "#ff4757"}], # Red for anomalies }, { "question_number": 2, "question": "Anomaly Description", - "question": "Describe the anomaly", "option_type": "input", "question_id": str(uuid.uuid4()), "required": True, - "options": [] + "options": [], }, { "question_number": 3, @@ -302,43 +284,48 @@ def create_project_polygon_input(api_key, api_secret, client_id, email, path_to_ "option_type": "input", "question_id": str(uuid.uuid4()), "required": False, - "options": [] - } + "options": [], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[polygon_input_project] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[polygon_input_project] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - -def create_project_input_select_radio(api_key, api_secret, client_id, email, path_to_images): - + + +def create_project_input_select_radio( + api_key, api_secret, client_id, email, path_to_images +): + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Dataset for evaluating and moderating image content', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'input_select_radio_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Dataset for evaluating and moderating image content", + "data_type": "image", + "created_by": email, + "project_name": "input_select_radio_project", + "annotation_guide": [ { "question_number": 1, "question": "Content Summary", "option_type": "input", "question_id": str(uuid.uuid4()), "required": True, - "options": [] + "options": [], }, { "question_number": 2, @@ -347,27 +334,12 @@ def create_project_input_select_radio(api_key, api_secret, client_id, email, pat "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Educational" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Entertainment" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Commercial" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "News" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Social" - } - ] + {"option_id": str(uuid.uuid4()), "option_name": "Educational"}, + {"option_id": str(uuid.uuid4()), "option_name": "Entertainment"}, + {"option_id": str(uuid.uuid4()), "option_name": "Commercial"}, + {"option_id": str(uuid.uuid4()), "option_name": "News"}, + {"option_id": str(uuid.uuid4()), "option_name": "Social"}, + ], }, { "question_number": 3, @@ -376,57 +348,51 @@ def create_project_input_select_radio(api_key, api_secret, client_id, email, pat "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Appropriate" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Needs Review" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Inappropriate" - } - ] - } + {"option_id": str(uuid.uuid4()), "option_name": "Appropriate"}, + {"option_id": str(uuid.uuid4()), "option_name": "Needs Review"}, + {"option_id": str(uuid.uuid4()), "option_name": "Inappropriate"}, + ], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[input_select_radio] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[input_select_radio] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - -def create_project_boundingbox_dropdown_input(api_key, api_secret, client_id, email, path_to_images): - + + +def create_project_boundingbox_dropdown_input( + api_key, api_secret, client_id, email, path_to_images +): + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Retail product images with bounding boxes and metadata', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'boundingbox_dropdown_input_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Retail product images with bounding boxes and metadata", + "data_type": "image", + "created_by": email, + "project_name": "boundingbox_dropdown_input_project", + "annotation_guide": [ { "question_number": 1, "question": "Product Bounding Box", "question_id": str(uuid.uuid4()), "option_type": "BoundingBox", "required": True, - "options": [ - {"option_name": "#2ed573"} # Green for products - ] + "options": [{"option_name": "#2ed573"}], # Green for products }, { "question_number": 2, @@ -435,27 +401,12 @@ def create_project_boundingbox_dropdown_input(api_key, api_secret, client_id, em "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Electronics" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Clothing" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Home & Garden" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Sports" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Books" - } - ] + {"option_id": str(uuid.uuid4()), "option_name": "Electronics"}, + {"option_id": str(uuid.uuid4()), "option_name": "Clothing"}, + {"option_id": str(uuid.uuid4()), "option_name": "Home & Garden"}, + {"option_id": str(uuid.uuid4()), "option_name": "Sports"}, + {"option_id": str(uuid.uuid4()), "option_name": "Books"}, + ], }, { "question_number": 3, @@ -463,7 +414,7 @@ def create_project_boundingbox_dropdown_input(api_key, api_secret, client_id, em "option_type": "input", "question_id": str(uuid.uuid4()), "required": True, - "options": [] + "options": [], }, { "question_number": 4, @@ -471,36 +422,41 @@ def create_project_boundingbox_dropdown_input(api_key, api_secret, client_id, em "option_type": "input", "question_id": str(uuid.uuid4()), "required": False, - "options": [] - } + "options": [], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[boundingbox_dropdown_input] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[boundingbox_dropdown_input] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - -def create_project_radio_dropdown(api_key, api_secret, client_id, email, path_to_images): - + + +def create_project_radio_dropdown( + api_key, api_secret, client_id, email, path_to_images +): + client = LabellerrClient(api_key, api_secret) project_payload = { - 'client_id': client_id, - 'dataset_name': 'Testing_dataset', - 'dataset_description': 'Simple dataset for quick image classification', - 'data_type': 'image', - 'created_by': email, - 'project_name': 'radio_dropdown_project', - 'annotation_guide': [ + "client_id": client_id, + "dataset_name": "Testing_dataset", + "dataset_description": "Simple dataset for quick image classification", + "data_type": "image", + "created_by": email, + "project_name": "radio_dropdown_project", + "annotation_guide": [ { "question_number": 1, "question": "Image Type", @@ -508,15 +464,9 @@ def create_project_radio_dropdown(api_key, api_secret, client_id, email, path_to "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Indoor" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Outdoor" - } - ] + {"option_id": str(uuid.uuid4()), "option_name": "Indoor"}, + {"option_id": str(uuid.uuid4()), "option_name": "Outdoor"}, + ], }, { "question_number": 2, @@ -525,43 +475,27 @@ def create_project_radio_dropdown(api_key, api_secret, client_id, email, path_to "question_id": str(uuid.uuid4()), "required": True, "options": [ - { - "option_id": str(uuid.uuid4()), - "option_name": "Person" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Animal" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Object" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Landscape" - }, - { - "option_id": str(uuid.uuid4()), - "option_name": "Architecture" - } - ] - } + {"option_id": str(uuid.uuid4()), "option_name": "Person"}, + {"option_id": str(uuid.uuid4()), "option_name": "Animal"}, + {"option_id": str(uuid.uuid4()), "option_name": "Object"}, + {"option_id": str(uuid.uuid4()), "option_name": "Landscape"}, + {"option_id": str(uuid.uuid4()), "option_name": "Architecture"}, + ], + }, ], - 'rotation_config': { - 'annotation_rotation_count': 1, - 'review_rotation_count': 1, - 'client_review_rotation_count': 1 + "rotation_config": { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, }, - 'autolabel': False, - 'folder_to_upload': path_to_images + "autolabel": False, + "folder_to_upload": path_to_images, } try: result = client.initiate_create_project(project_payload) - print(f"[radio_dropdown] Project ID: {result['project_id']['response']['project_id']}") + print( + f"[radio_dropdown] Project ID: {result['project_id']['response']['project_id']}" + ) except LabellerrError as e: print(f"Project creation failed: {str(e)}") - - - diff --git a/tests/integration/Export_project.py b/tests/integration/Export_project.py index 100d56e..26964db 100644 --- a/tests/integration/Export_project.py +++ b/tests/integration/Export_project.py @@ -1,14 +1,16 @@ -import sys import os -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'SDKPython'))) +import sys + +sys.path.append( + 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__), '..', '..')) +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 -import uuid def export_project(api_key, api_secret, client_id, project_id): @@ -19,12 +21,18 @@ def export_project(api_key, api_secret, client_id, project_id): "export_name": "Weekly Export", "export_description": "Export of all accepted annotations", "export_format": "coco_json", - "statuses": ['review', 'r_assigned','client_review', 'cr_assigned','accepted'] + "statuses": [ + "review", + "r_assigned", + "client_review", + "cr_assigned", + "accepted", + ], } try: result = client.create_local_export(project_id, client_id, export_config) - export_id = result["response"]['report_id'] + export_id = result["response"]["report_id"] print(f"Local export created successfully. Export ID: {export_id}") except LabellerrError as e: - print(f"Local export creation failed: {str(e)}") \ No newline at end of file + print(f"Local export creation failed: {str(e)}") diff --git a/tests/integration/Pre_annotation_uploading.py b/tests/integration/Pre_annotation_uploading.py index ac4c5f5..c077434 100644 --- a/tests/integration/Pre_annotation_uploading.py +++ b/tests/integration/Pre_annotation_uploading.py @@ -1,26 +1,33 @@ -import sys import os -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'SDKPython'))) +import sys + +sys.path.append( + 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__), '..', '..')) +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 -import uuid -def pre_annotation_uploading(api_key, api_secret, client_id, project_id, annotation_format, annotation_file): - + +def pre_annotation_uploading( + api_key, api_secret, client_id, project_id, annotation_format, annotation_file +): + client = LabellerrClient(api_key, api_secret) try: # Upload and wait for processing to complete - result = client.upload_preannotation_by_project_id(project_id, client_id, annotation_format, annotation_file) + result = client.upload_preannotation_by_project_id( + project_id, client_id, annotation_format, annotation_file + ) # Check the final status - if result['response']['status'] == 'completed': + if result["response"]["status"] == "completed": print("Pre-annotations processed successfully") # Access additional metadata if needed - metadata = result['response'].get('metadata', {}) - print("metadata",metadata) + metadata = result["response"].get("metadata", {}) + print("metadata", metadata) except LabellerrError as e: - print(f"Pre-annotation upload failed: {str(e)}") \ No newline at end of file + print(f"Pre-annotation upload failed: {str(e)}") diff --git a/tests/integration/cred.py b/tests/integration/cred.py index 27f7313..1735744 100644 --- a/tests/integration/cred.py +++ b/tests/integration/cred.py @@ -3,4 +3,4 @@ CLIENT_ID = "" PROJECT_ID = "" -EMAIL_ID = "" \ No newline at end of file +EMAIL_ID = "" diff --git a/tests/integration/main.py b/tests/integration/main.py index 3a4aa9e..3f0b4f3 100644 --- a/tests/integration/main.py +++ b/tests/integration/main.py @@ -1,6 +1,14 @@ -from Create_Project import * -from Export_project import export_project import cred +from Create_Project import ( + create_project_all_option_type, + create_project_boundingbox_dropdown_input, + create_project_input_select_radio, + create_project_polygon_boundingbox_project, + create_project_polygon_input, + create_project_radio_dropdown, + create_project_select_dropdown_radio, +) +from Export_project import export_project from Pre_annotation_uploading import pre_annotation_uploading api_key = cred.API_KEY @@ -10,55 +18,65 @@ email = cred.EMAIL_ID - def test_create_project(path_to_images): print("CREATING PROJECTS WITH DIFFERENT OPTION TYPE") print("\n 1:project with all option type") - create_project_all_option_type(api_key, api_secret, client_id, email, path_to_images) - + create_project_all_option_type( + api_key, api_secret, client_id, email, path_to_images + ) + print("\n 2:project with polygon and bounding box") - create_project_polygon_boundingbox_project(api_key, api_secret, client_id, email, path_to_images) - + create_project_polygon_boundingbox_project( + api_key, api_secret, client_id, email, path_to_images + ) + print("\n 3:project with select, dropdown and radio") - create_project_select_dropdown_radio(api_key, api_secret, client_id, email, path_to_images) - + create_project_select_dropdown_radio( + api_key, api_secret, client_id, email, path_to_images + ) + print("\n 4:project with polygon and input") create_project_polygon_input(api_key, api_secret, client_id, email, path_to_images) - + print("\n 5:project with input, select and radio") - create_project_input_select_radio(api_key, api_secret, client_id, email, path_to_images) - + create_project_input_select_radio( + api_key, api_secret, client_id, email, path_to_images + ) + print("\n 6:project with bounding box, dropdown and input") - create_project_boundingbox_dropdown_input(api_key, api_secret, client_id, email, path_to_images) - + create_project_boundingbox_dropdown_input( + api_key, api_secret, client_id, email, path_to_images + ) + print("\n 7:project with radio and dropdown") create_project_radio_dropdown(api_key, api_secret, client_id, email, path_to_images) - + print("\n Project creation completed.") - + + def test_export_project(project_id): print("\n EXPORTING PROJECT") export_project(api_key, api_secret, client_id, project_id) print("\n Project export completed.") - + + def test_pre_annotation_uploading(project_id, annotation_format, annotation_file): print("\n PRE-ANNOTATION UPLOADING") - pre_annotation_uploading(api_key, api_secret, client_id, project_id, annotation_format, annotation_file) + pre_annotation_uploading( + api_key, api_secret, client_id, project_id, annotation_format, annotation_file + ) print("\n Pre-annotation uploading completed.") - + + if __name__ == "__main__": - - test_dataset_path = r'D:\professional\LABELLERR\Task\LABIMP-7059-SDK-Testing\test_img_6' + + test_dataset_path = ( + r"D:\professional\LABELLERR\Task\LABIMP-7059-SDK-Testing\test_img_6" + ) test_create_project(test_dataset_path) - + test_export_project(project_id) - - json_annotation_file = r'D:\professional\LABELLERR\Task\LABIMP-7059-SDK-Testing\test_img_6_annotations.json' - test_pre_annotation_uploading(project_id, 'coco_json', json_annotation_file) - - - - - - \ No newline at end of file + + json_annotation_file = r"D:\professional\LABELLERR\Task\LABIMP-7059-SDK-Testing\test_img_6_annotations.json" + test_pre_annotation_uploading(project_id, "coco_json", json_annotation_file) diff --git a/tests/test_client.py b/tests/test_client.py index 6245f3e..e4df40a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,7 @@ import os -import uuid -from unittest.mock import patch import pytest +from pydantic import ValidationError from labellerr.client import LabellerrClient from labellerr.exceptions import LabellerrError @@ -31,7 +30,7 @@ def sample_valid_payload(): "dataset_name": "Test Dataset", "dataset_description": "Dataset for testing", "data_type": "image", - "created_by": "test_user", + "created_by": "test_user@example.com", "project_name": "Test Project", "autolabel": False, "files_to_upload": [test_image], @@ -52,67 +51,6 @@ def sample_valid_payload(): class TestInitiateCreateProject: - @patch("labellerr.client.LabellerrClient.create_dataset") - @patch("labellerr.client.LabellerrClient.get_dataset") - @patch("labellerr.client.utils.poll") - @patch("labellerr.client.LabellerrClient.create_annotation_guideline") - @patch("labellerr.client.LabellerrClient.create_project") - def test_successful_project_creation( - self, - mock_create_project, - mock_create_guideline, - mock_poll, - mock_get_dataset, - mock_create_dataset, - client, - sample_valid_payload, - ): - """Test successful project creation flow""" - # Configure mocks - dataset_id = str(uuid.uuid4()) - mock_create_dataset.return_value = { - "response": "success", - "dataset_id": dataset_id, - } - - mock_get_dataset.return_value = {"response": {"status_code": 300}} - - mock_poll.return_value = {"response": {"status_code": 300}} - - template_id = str(uuid.uuid4()) - mock_create_guideline.return_value = template_id - - expected_project_response = { - "response": "success", - "project_id": str(uuid.uuid4()), - } - mock_create_project.return_value = expected_project_response - - # Execute - result = client.initiate_create_project(sample_valid_payload) - - # Assert - assert result["status"] == "success" - assert "message" in result - assert "project_id" in result - mock_create_dataset.assert_called_once() - mock_poll.assert_called_once() - mock_create_guideline.assert_called_once_with( - sample_valid_payload["client_id"], - sample_valid_payload["annotation_guide"], - sample_valid_payload["project_name"], - sample_valid_payload["data_type"], - ) - mock_create_project.assert_called_once_with( - project_name=sample_valid_payload["project_name"], - data_type=sample_valid_payload["data_type"], - client_id=sample_valid_payload["client_id"], - dataset_id=dataset_id, - annotation_template_id=template_id, - rotation_config=sample_valid_payload["rotation_config"], - created_by=sample_valid_payload["created_by"], - ) - def test_missing_required_parameters(self, client, sample_valid_payload): """Test error handling for missing required parameters""" # Remove required parameters one by one and test @@ -123,7 +61,6 @@ def test_missing_required_parameters(self, client, sample_valid_payload): "data_type", "created_by", "project_name", - "annotation_guide", "autolabel", ] @@ -136,6 +73,18 @@ def test_missing_required_parameters(self, client, sample_valid_payload): assert f"Required parameter {param} is missing" in str(exc_info.value) + # Test annotation_guide separately since it has special validation + invalid_payload = sample_valid_payload.copy() + del invalid_payload["annotation_guide"] + + with pytest.raises(LabellerrError) as exc_info: + client.initiate_create_project(invalid_payload) + + assert ( + "Please provide either annotation guide or annotation template id" + in str(exc_info.value) + ) + def test_invalid_client_id(self, client, sample_valid_payload): """Test error handling for invalid client_id""" invalid_payload = sample_valid_payload.copy() @@ -219,95 +168,259 @@ def test_invalid_folder_to_upload(self, client, sample_valid_payload): assert "Folder path does not exist" in str(exc_info.value) - @patch("labellerr.client.LabellerrClient.create_dataset") - def test_create_dataset_error( - self, mock_create_dataset, client, sample_valid_payload - ): - """Test error handling when create_dataset fails""" - error_message = "Failed to create dataset" - mock_create_dataset.side_effect = LabellerrError(error_message) - with pytest.raises(LabellerrError) as exc_info: - client.initiate_create_project(sample_valid_payload) +class TestCreateUser: + """Test cases for create_user method""" + + def test_create_user_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.create_user( + client_id="12345", + first_name="John", + last_name="Doe", + # Missing email_id, projects, roles + ) - assert error_message in str(exc_info.value) + assert "missing" in str(exc_info.value).lower() - @patch("labellerr.client.LabellerrClient.create_dataset") - @patch("labellerr.client.utils.poll") - def test_poll_timeout( - self, mock_poll, mock_create_dataset, client, sample_valid_payload - ): - """Test handling when dataset polling times out""" - dataset_id = str(uuid.uuid4()) - mock_create_dataset.return_value = { - "response": "success", - "dataset_id": dataset_id, - } + def test_create_user_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.create_user( + client_id=12345, # Not a string + first_name="John", + last_name="Doe", + email_id="john@example.com", + projects=["project_1"], + roles=[{"project_id": "project_1", "role_id": 7}], + ) + + assert "client_id" in str(exc_info.value).lower() + + def test_create_user_empty_projects(self, client): + """Test error handling for empty projects list""" + with pytest.raises(ValidationError) as exc_info: + client.create_user( + client_id="12345", + first_name="John", + last_name="Doe", + email_id="john@example.com", + projects=[], # Empty list + roles=[{"project_id": "project_1", "role_id": 7}], + ) + + assert "projects" in str(exc_info.value).lower() + + def test_create_user_empty_roles(self, client): + """Test error handling for empty roles list""" + with pytest.raises(ValidationError) as exc_info: + client.create_user( + client_id="12345", + first_name="John", + last_name="Doe", + email_id="john@example.com", + projects=["project_1"], + roles=[], # Empty list + ) + + assert "roles" in str(exc_info.value).lower() + + +class TestUpdateUserRole: + """Test cases for update_user_role method""" + + def test_update_user_role_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.update_user_role( + client_id="12345", + project_id="project_123", + # Missing email_id, roles + ) - # Poll returns None when it times out - mock_poll.return_value = None + assert "missing" in str(exc_info.value).lower() - with pytest.raises(LabellerrError): - client.initiate_create_project(sample_valid_payload) - - @patch("labellerr.client.LabellerrClient.create_dataset") - @patch("labellerr.client.utils.poll") - @patch("labellerr.client.LabellerrClient.create_annotation_guideline") - def test_create_guideline_error( - self, - mock_create_guideline, - mock_poll, - mock_create_dataset, - client, - sample_valid_payload, - ): - """Test error handling when create_annotation_guideline fails""" - dataset_id = str(uuid.uuid4()) - mock_create_dataset.return_value = { - "response": "success", - "dataset_id": dataset_id, - } - mock_poll.return_value = {"response": {"status_code": 300}} - - error_message = "Failed to create annotation guideline" - mock_create_guideline.side_effect = LabellerrError(error_message) + def test_update_user_role_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.update_user_role( + client_id=12345, # Not a string + project_id="project_123", + email_id="john@example.com", + roles=[{"project_id": "project_1", "role_id": 7}], + ) - with pytest.raises(LabellerrError) as exc_info: - client.initiate_create_project(sample_valid_payload) - - assert error_message in str(exc_info.value) - - @patch("labellerr.client.LabellerrClient.create_dataset") - @patch("labellerr.client.utils.poll") - @patch("labellerr.client.LabellerrClient.create_annotation_guideline") - @patch("labellerr.client.LabellerrClient.create_project") - def test_create_project_error( - self, - mock_create_project, - mock_create_guideline, - mock_poll, - mock_create_dataset, - client, - sample_valid_payload, - ): - """Test error handling when create_project fails""" - dataset_id = str(uuid.uuid4()) - mock_create_dataset.return_value = { - "response": "success", - "dataset_id": dataset_id, - } - mock_poll.return_value = {"response": {"status_code": 300}} - - template_id = str(uuid.uuid4()) - mock_create_guideline.return_value = template_id - - error_message = "Failed to create project" - mock_create_project.side_effect = LabellerrError(error_message) + assert "client_id" in str(exc_info.value).lower() - with pytest.raises(LabellerrError) as exc_info: - client.initiate_create_project(sample_valid_payload) + def test_update_user_role_empty_roles(self, client): + """Test error handling for empty roles list""" + with pytest.raises(ValidationError) as exc_info: + client.update_user_role( + client_id="12345", + project_id="project_123", + email_id="john@example.com", + roles=[], # Empty list + ) + + assert "roles" in str(exc_info.value).lower() + + +class TestDeleteUser: + """Test cases for delete_user method""" + + def test_delete_user_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.delete_user( + client_id="12345", + project_id="project_123", + # Missing email_id, user_id + ) + + assert "missing" in str(exc_info.value).lower() + + def test_delete_user_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.delete_user( + client_id=12345, # Not a string + project_id="project_123", + email_id="john@example.com", + user_id="user_123", + ) + + assert "client_id" in str(exc_info.value).lower() + + def test_delete_user_invalid_project_id(self, client): + """Test error handling for invalid project_id""" + with pytest.raises(ValidationError) as exc_info: + client.delete_user( + client_id="12345", + project_id=12345, # Not a string + email_id="john@example.com", + user_id="user_123", + ) + + assert "project_id" in str(exc_info.value).lower() + + def test_delete_user_invalid_email_id(self, client): + """Test error handling for invalid email_id""" + with pytest.raises(ValidationError) as exc_info: + client.delete_user( + client_id="12345", + project_id="project_123", + email_id=12345, # Not a string + user_id="user_123", + ) + + assert "email_id" in str(exc_info.value).lower() + + def test_delete_user_invalid_user_id(self, client): + """Test error handling for invalid user_id""" + with pytest.raises(ValidationError) as exc_info: + client.delete_user( + client_id="12345", + project_id="project_123", + email_id="john@example.com", + user_id=12345, # Not a string + ) + + assert "user_id" in str(exc_info.value).lower() + + +class TestAddUserToProject: + """Test cases for add_user_to_project method""" + + def test_add_user_to_project_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.add_user_to_project( + client_id="12345", + project_id="project_123", + # Missing email_id + ) + + assert "missing" in str(exc_info.value).lower() + + def test_add_user_to_project_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.add_user_to_project( + client_id=12345, # Not a string + project_id="project_123", + email_id="john@example.com", + ) + + assert "client_id" in str(exc_info.value).lower() + + +class TestRemoveUserFromProject: + """Test cases for remove_user_from_project method""" + + def test_remove_user_from_project_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.remove_user_from_project( + client_id="12345", + project_id="project_123", + # Missing email_id + ) + + assert "missing" in str(exc_info.value).lower() + + def test_remove_user_from_project_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.remove_user_from_project( + client_id=12345, # Not a string + project_id="project_123", + email_id="john@example.com", + ) + + assert "client_id" in str(exc_info.value).lower() + + +class TestChangeUserRole: + """Test cases for change_user_role method""" + + def test_change_user_role_missing_required_params(self, client): + """Test error handling for missing required parameters""" + with pytest.raises(TypeError) as exc_info: + client.change_user_role( + client_id="12345", + project_id="project_123", + email_id="john@example.com", + # Missing new_role_id + ) + + assert "missing" in str(exc_info.value).lower() + + def test_change_user_role_invalid_client_id(self, client): + """Test error handling for invalid client_id""" + with pytest.raises(ValidationError) as exc_info: + client.change_user_role( + client_id=12345, # Not a string + project_id="project_123", + email_id="john@example.com", + new_role_id="7", + ) + + assert "client_id" in str(exc_info.value).lower() + + +class TestListAndBulkAssignFiles: + """Tests for list_file and bulk_assign_files methods""" + + def test_list_file_missing_required(self, client): + with pytest.raises(TypeError): + client.list_file(client_id="12345", project_id="project_123") - assert error_message in str(exc_info.value) + def test_bulk_assign_files_missing_required(self, client): + with pytest.raises(TypeError): + client.bulk_assign_files( + client_id="12345", project_id="project_123", new_status="None" + ) if __name__ == "__main__": diff --git a/tests/test_keyframes.py b/tests/test_keyframes.py new file mode 100644 index 0000000..759fa62 --- /dev/null +++ b/tests/test_keyframes.py @@ -0,0 +1,435 @@ +from unittest.mock import Mock, patch + +import pytest + +from labellerr.client import KeyFrame, LabellerrClient, validate_params +from labellerr.exceptions import LabellerrError + + +class TestKeyFrame: + """Unit tests for KeyFrame dataclass""" + + @pytest.mark.parametrize( + "frame_number,is_manual,method,source,expected", + [ + # Valid creation with defaults + ( + 10, + None, + None, + None, + { + "frame_number": 10, + "is_manual": True, + "method": "manual", + "source": "manual", + }, + ), + # Custom values + ( + 5, + False, + "automatic", + "ai", + { + "frame_number": 5, + "is_manual": False, + "method": "automatic", + "source": "ai", + }, + ), + # Edge cases + ( + 0, + True, + "manual", + "manual", + { + "frame_number": 0, + "is_manual": True, + "method": "manual", + "source": "manual", + }, + ), + ( + 999, + False, + "ai", + "system", + { + "frame_number": 999, + "is_manual": False, + "method": "ai", + "source": "system", + }, + ), + ], + ) + def test_keyframe_valid_creation( + self, frame_number, is_manual, method, source, expected + ): + """Test creating valid KeyFrame objects with various parameters""" + kwargs = {"frame_number": frame_number} + if is_manual is not None: + kwargs["is_manual"] = is_manual + if method is not None: + kwargs["method"] = method + if source is not None: + kwargs["source"] = source + + keyframe = KeyFrame(**kwargs) + assert keyframe.__dict__ == expected + + @pytest.mark.parametrize( + "invalid_params,expected_error", + [ + # Invalid frame_number + ({"frame_number": "not_an_int"}, "frame_number must be an integer"), + ({"frame_number": 1.5}, "frame_number must be an integer"), + ({"frame_number": None}, "frame_number must be an integer"), + # Invalid is_manual + ( + {"frame_number": 1, "is_manual": "not_a_bool"}, + "is_manual must be a boolean", + ), + ({"frame_number": 1, "is_manual": 1}, "is_manual must be a boolean"), + # Invalid method + ({"frame_number": 1, "method": 123}, "method must be a string"), + ({"frame_number": 1, "method": []}, "method must be a string"), + # Invalid source + ({"frame_number": 1, "source": 456}, "source must be a string"), + ({"frame_number": 1, "source": {}}, "source must be a string"), + ], + ) + def test_keyframe_invalid_creation(self, invalid_params, expected_error): + """Test KeyFrame creation with invalid parameters""" + with pytest.raises(ValueError, match=expected_error): + KeyFrame(**invalid_params) + + +class TestValidateParamsDecorator: + """Unit tests for validate_params decorator""" + + @pytest.mark.parametrize( + "validation_spec,args,kwargs,expected_result", + [ + # Single type validation + ({"param1": str, "param2": int}, ("hello", 42), {}, "hello_42"), + # Union types + ({"param1": (str, int)}, ("hello",), {}, "hello"), + ({"param1": (str, int)}, (42,), {}, 42), + # Keyword arguments + ({"param1": str, "param2": int}, ("hello",), {"param2": 20}, "hello_20"), + # Missing optional parameter + ({"param1": str, "param2": int}, ("hello",), {}, "hello_10"), + ], + ) + def test_validate_params_valid_cases( + self, validation_spec, args, kwargs, expected_result + ): + """Test validation decorator with valid parameters""" + if len(validation_spec) == 1 and "param1" in validation_spec: + + @validate_params(**validation_spec) + def test_func(param1): + return param1 + + else: + + @validate_params(**validation_spec) + def test_func(param1, param2=10): + return f"{param1}_{param2}" + + result = test_func(*args, **kwargs) + assert result == expected_result + + @pytest.mark.parametrize( + "validation_spec,args,kwargs,expected_error", + [ + # Invalid single type + ({"param1": str}, (123,), {}, "param1 must be a str"), + # Invalid union type + ({"param1": (str, int)}, ([1, 2, 3],), {}, "param1 must be a str or int"), + # Invalid type with multiple params + ( + {"param1": str, "param2": int}, + ("hello", "not_int"), + {}, + "param2 must be a int", + ), + ], + ) + def test_validate_params_invalid_cases( + self, validation_spec, args, kwargs, expected_error + ): + """Test validation decorator with invalid parameters""" + + @validate_params(**validation_spec) + def test_func(param1, param2=10): + return f"{param1}_{param2}" + + with pytest.raises(LabellerrError, match=expected_error): + test_func(*args, **kwargs) + + +@pytest.fixture +def mock_client(): + """Create a mock client for testing""" + client = LabellerrClient("test_api_key", "test_api_secret") + client.base_url = "https://api.labellerr.com" + return client + + +class TestLinkKeyFrameMethod: + """Unit tests for link_key_frame method""" + + @patch("labellerr.client.LabellerrClient._make_request") + @patch("labellerr.client.LabellerrClient._handle_response") + def test_link_key_frame_success( + self, mock_handle_response, mock_make_request, mock_client + ): + """Test successful key frame linking""" + # Arrange + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"status": "success"} + + keyframes = [ + KeyFrame(frame_number=0), + KeyFrame(frame_number=10, is_manual=False), + ] + + # Act + result = mock_client.link_key_frame( + "test_client", "test_project", "test_file", keyframes + ) + + # Assert + assert result == {"status": "success"} + mock_make_request.assert_called_once() + args, kwargs = mock_make_request.call_args + + assert args[0] == "POST" + assert "/actions/add_update_keyframes" in args[1] + assert "client_id=test_client" in args[1] + assert kwargs["headers"]["content-type"] == "application/json" + + expected_body = { + "project_id": "test_project", + "file_id": "test_file", + "keyframes": [ + { + "frame_number": 0, + "is_manual": True, + "method": "manual", + "source": "manual", + }, + { + "frame_number": 10, + "is_manual": False, + "method": "manual", + "source": "manual", + }, + ], + } + assert kwargs["json"] == expected_body + + @pytest.mark.parametrize( + "client_id,project_id,file_id,keyframes,expected_error", + [ + # Invalid client_id + ( + 123, + "test_project", + "test_file", + [KeyFrame(frame_number=0)], + "client_id must be a str", + ), + ( + None, + "test_project", + "test_file", + [KeyFrame(frame_number=0)], + "client_id must be a str", + ), + ( + [], + "test_project", + "test_file", + [KeyFrame(frame_number=0)], + "client_id must be a str", + ), + # Invalid project_id + ( + "test_client", + 456, + "test_file", + [KeyFrame(frame_number=0)], + "project_id must be a str", + ), + ( + "test_client", + None, + "test_file", + [KeyFrame(frame_number=0)], + "project_id must be a str", + ), + # Invalid file_id + ( + "test_client", + "test_project", + 789, + [KeyFrame(frame_number=0)], + "file_id must be a str", + ), + ( + "test_client", + "test_project", + {}, + [KeyFrame(frame_number=0)], + "file_id must be a str", + ), + # Invalid keyframes + ( + "test_client", + "test_project", + "test_file", + "not_a_list", + "key_frames must be a list", + ), + ( + "test_client", + "test_project", + "test_file", + 123, + "key_frames must be a list", + ), + ( + "test_client", + "test_project", + "test_file", + None, + "key_frames must be a list", + ), + ], + ) + def test_link_key_frame_invalid_parameters( + self, mock_client, client_id, project_id, file_id, keyframes, expected_error + ): + """Test link_key_frame with various invalid parameters""" + with pytest.raises(LabellerrError, match=expected_error): + mock_client.link_key_frame(client_id, project_id, file_id, keyframes) + + @patch("labellerr.client.LabellerrClient._make_request") + def test_link_key_frame_api_error(self, mock_make_request, mock_client): + """Test link_key_frame when API call fails""" + mock_make_request.side_effect = Exception("API Error") + keyframes = [KeyFrame(frame_number=0)] + + with pytest.raises( + LabellerrError, match="Failed to link key frames: API Error" + ): + mock_client.link_key_frame( + "test_client", "test_project", "test_file", keyframes + ) + + @patch("labellerr.client.LabellerrClient._make_request") + @patch("labellerr.client.LabellerrClient._handle_response") + def test_link_key_frame_with_dict_keyframes( + self, mock_handle_response, mock_make_request, mock_client + ): + """Test link_key_frame with dictionary keyframes instead of KeyFrame objects""" + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"status": "success"} + + keyframes = [ + { + "frame_number": 0, + "is_manual": True, + "method": "manual", + "source": "manual", + } + ] + + result = mock_client.link_key_frame( + "test_client", "test_project", "test_file", keyframes + ) + + assert result == {"status": "success"} + args, kwargs = mock_make_request.call_args + expected_body = { + "project_id": "test_project", + "file_id": "test_file", + "keyframes": keyframes, + } + assert kwargs["json"] == expected_body + + +class TestDeleteKeyFramesMethod: + """Unit tests for delete_key_frames method""" + + @patch("labellerr.client.LabellerrClient._make_request") + @patch("labellerr.client.LabellerrClient._handle_response") + def test_delete_key_frames_success( + self, mock_handle_response, mock_make_request, mock_client + ): + """Test successful key frame deletion""" + # Arrange + mock_response = Mock() + mock_make_request.return_value = mock_response + mock_handle_response.return_value = {"status": "deleted"} + + # Act + result = mock_client.delete_key_frames("test_client", "test_project") + + # Assert + assert result == {"status": "deleted"} + mock_make_request.assert_called_once() + args, _ = mock_make_request.call_args + + assert args[0] == "POST" + assert "/actions/delete_keyframes" in args[1] + assert "project_id=test_project" in args[1] + assert "client_id=test_client" in args[1] + assert "uuid=" in args[1] + + @pytest.mark.parametrize( + "client_id,project_id,expected_error", + [ + # Invalid client_id + (123, "test_project", "client_id must be a str"), + (None, "test_project", "client_id must be a str"), + ([], "test_project", "client_id must be a str"), + ({}, "test_project", "client_id must be a str"), + # Invalid project_id + ("test_client", 456, "project_id must be a str"), + ("test_client", None, "project_id must be a str"), + ("test_client", [], "project_id must be a str"), + ("test_client", {}, "project_id must be a str"), + ], + ) + def test_delete_key_frames_invalid_parameters( + self, mock_client, client_id, project_id, expected_error + ): + """Test delete_key_frames with various invalid parameters""" + with pytest.raises(LabellerrError, match=expected_error): + mock_client.delete_key_frames(client_id, project_id) + + @patch("labellerr.client.LabellerrClient._make_request") + def test_delete_key_frames_api_error(self, mock_make_request, mock_client): + """Test delete_key_frames when API call fails""" + mock_make_request.side_effect = Exception("API Error") + + with pytest.raises( + LabellerrError, match="Failed to delete key frames: API Error" + ): + mock_client.delete_key_frames("test_client", "test_project") + + @patch("labellerr.client.LabellerrClient._make_request") + def test_delete_key_frames_labellerr_error(self, mock_make_request, mock_client): + """Test delete_key_frames when LabellerrError is raised""" + mock_make_request.side_effect = LabellerrError("Custom error") + + with pytest.raises(LabellerrError, match="Custom error"): + mock_client.delete_key_frames("test_client", "test_project") diff --git a/tests/test_keyframes_integration.py b/tests/test_keyframes_integration.py new file mode 100644 index 0000000..9c96ad2 --- /dev/null +++ b/tests/test_keyframes_integration.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