diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 618d144..3b9d504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: python-version: ['3.9'] - + steps: - name: Checkout code uses: actions/checkout@v4 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/labellerr/client.py b/labellerr/client.py index aaa7281..d356bce 100644 --- a/labellerr/client.py +++ b/labellerr/client.py @@ -7,7 +7,10 @@ import time import uuid from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from functools import wraps from multiprocessing import cpu_count +from typing import List, Optional, Union import requests from requests.adapters import HTTPAdapter @@ -20,6 +23,65 @@ create_dataset_parameters = {} +@dataclass +class KeyFrame: + """ + Represents a key frame with validation. + """ + frame_number: int + is_manual: bool = True + method: str = "manual" + source: str = "manual" + + def __post_init__(self): + 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") + if not isinstance(self.is_manual, bool): + raise ValueError("is_manual must be a boolean") + if not isinstance(self.method, str): + raise ValueError("method must be a string") + if not isinstance(self.source, str): + raise ValueError("source must be a string") + + +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 + + class LabellerrClient: """ A client for interacting with the Labellerr API. @@ -127,7 +189,7 @@ def _build_headers(self, client_id=None, extra_headers=None): api_secret=self.api_secret, source="sdk", client_id=client_id, - extra_headers=extra_headers, + extra_headers=extra_headers ) def _handle_response(self, response, request_id=None, success_codes=None): @@ -275,13 +337,12 @@ def __process_batch(self, client_id, files_list, connection_id=None): return response - def upload_files(self, client_id, files_list): + @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 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. @@ -290,6 +351,7 @@ def upload_files(self, client_id, files_list): # Convert string input to list if necessary if isinstance(files_list, str): files_list = files_list.split(",") + 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" @@ -426,24 +488,18 @@ def create_dataset( logging.error(f"Failed to create dataset: {e}") raise - def get_all_dataset(self, client_id, datatype, project_id, scope): + @validate_params(client_id=str, datatype=str, project_id=str, scope=str) + def get_all_dataset(self, client_id: str, datatype: str, project_id: str, scope: str): """ Retrieves a dataset by its ID. :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 scope of the dataset. :return: The dataset as JSON. """ - # 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 + # scope value should be in the list SCOPE_LIST if scope not in constants.SCOPE_LIST: raise LabellerrError( f"scope must be one of {', '.join(constants.SCOPE_LIST)}" @@ -622,9 +678,7 @@ def _upload_preannotation_sync( "annotation_format": annotation_format, "annotation_file": annotation_file, } - client_utils.validate_required_params( - required_params, list(required_params.keys()) - ) + client_utils.validate_required_params(required_params, list(required_params.keys())) 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}" @@ -924,7 +978,9 @@ def create_local_export(self, project_id, client_id, export_config): logging.error(f"Failed to create local export: {str(e)}") raise LabellerrError(f"Failed to create local export: {str(e)}") - def fetch_download_url(self, project_id, uuid, export_id, client_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"} @@ -954,13 +1010,16 @@ def fetch_download_url(self, project_id, uuid, export_id, client_id): logging.error(f"Unexpected error in download_function: {str(e)}") raise LabellerrError(f"Unexpected error in download_function: {str(e)}") - 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}" @@ -1335,3 +1394,65 @@ def create_batches(): raise e except Exception as e: raise LabellerrError(f"Failed to upload files: {str(e)}") + + + @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 = self._build_headers( + client_id=client_id, + extra_headers={"content-type": "application/json"} + ) + + 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: + raise e + except Exception as e: + raise LabellerrError(f"Failed to link key frames: {str(e)}") + + @validate_params(client_id=str, project_id=str) + def delete_key_frames(self, client_id: str, project_id: str): + """ + Deletes key frames from a project. + + :param client_id: The ID of the client + :param project_id: The ID of the project + :return: Response from the API + """ + try: + 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 = self._build_headers( + client_id=client_id, + extra_headers={"content-type": "application/json"} + ) + + response = self._make_request("POST", url, headers=headers) + return self._handle_response(response, unique_id) + + except LabellerrError as e: + raise e + except Exception as e: + raise LabellerrError(f"Failed to delete key frames: {str(e)}") diff --git a/tests/test_keyframes.py b/tests/test_keyframes.py new file mode 100644 index 0000000..1d1a144 --- /dev/null +++ b/tests/test_keyframes.py @@ -0,0 +1,267 @@ +import pytest +from unittest.mock import Mock, patch +import uuid + +from labellerr.client import LabellerrClient, KeyFrame, 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") \ No newline at end of file diff --git a/tests/test_keyframes_integration.py b/tests/test_keyframes_integration.py new file mode 100644 index 0000000..f199a5a --- /dev/null +++ b/tests/test_keyframes_integration.py @@ -0,0 +1,303 @@ +import os +import pytest +from labellerr.client import LabellerrClient, KeyFrame +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 = [ + KeyFrame(frame_number=0, is_manual=True, method="manual", source="annotator"), # Start frame + KeyFrame(frame_number=150, is_manual=False, method="ai_detection", source="cv_model"), # AI detected movement + KeyFrame(frame_number=300, is_manual=True, method="manual", source="annotator"), # Important scene change + KeyFrame(frame_number=450, is_manual=False, method="ai_detection", source="cv_model"), # AI detected object + KeyFrame(frame_number=600, is_manual=True, method="manual", source="annotator") # End of segment + ] + + # 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 = [ + KeyFrame(frame_number=0, is_manual=True, method="manual", source="operator"), # Review start + KeyFrame(frame_number=2340, is_manual=False, method="motion_detection", source="ai"), # Auto-detected motion + KeyFrame(frame_number=2380, is_manual=True, method="manual", source="operator"), # Operator verification + KeyFrame(frame_number=2420, is_manual=False, method="face_detection", source="ai"), # Face detected + KeyFrame(frame_number=2500, is_manual=True, method="manual", source="operator") # Incident end + ] + + 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 = [ + KeyFrame(frame_number=100, is_manual=True, method="manual", source="inspector"), # Inspection start + KeyFrame(frame_number=500, is_manual=True, method="manual", source="inspector"), # Potential defect spotted + KeyFrame(frame_number=1200, is_manual=False, method="anomaly_detection", source="ai"), # AI flagged anomaly + KeyFrame(frame_number=1800, is_manual=True, method="manual", source="inspector") # Confirmed defect + ] + + 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 \ No newline at end of file