diff --git a/.flake8 b/.flake8 index 6ef4364..fb743b2 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 200 extend-ignore = E203, W503, E402, F405 -exclude = .git,__pycache__,.venv,build,dist,venv,driver.py +exclude = .git,__pycache__,.venv,build,dist,venv,driver.py,.history diff --git a/.gitignore b/.gitignore index 1812a6f..4a8ebad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ download labellerr/__pycache__/ env.* claude.md +.history/ diff --git a/labellerr/core/exports/base.py b/labellerr/core/exports/base.py index 39ebb63..310e80e 100644 --- a/labellerr/core/exports/base.py +++ b/labellerr/core/exports/base.py @@ -39,9 +39,6 @@ def report_id(self) -> str: def _status(self) -> Dict[str, Any]: """Get current export status (single check).""" response = self._project.check_export_status([self._report_id]) - if isinstance(response, str): - response = json.loads(response) - for status_item in response.get("status", []): if status_item.get("report_id") == self._report_id: return status_item diff --git a/labellerr/core/projects/base.py b/labellerr/core/projects/base.py index c7967bf..78942bb 100644 --- a/labellerr/core/projects/base.py +++ b/labellerr/core/projects/base.py @@ -467,7 +467,7 @@ def create_export(self, export_config: schemas.CreateExportParams): report_id = response.get("response", {}).get("report_id") return Export(report_id=report_id, project=self) - def create_local_export(self, export_config): + def create_local_export(self, export_config: schemas.CreateExportParams): """ Creates a local export with the given configuration. @@ -475,13 +475,15 @@ def create_local_export(self, export_config): :return: Export instance with report_id and status tracking :raises LabellerrError: If the export creation fails """ - # Validate export config using client_utils - client_utils.validate_export_config(export_config) unique_id = client_utils.generate_request_id() - export_config.update({"export_destination": "local", "question_ids": ["all"]}) - payload = json.dumps(export_config) + export_config_dict = export_config.model_dump() + export_config_dict.update( + {"export_destination": schemas.ExportDestination.LOCAL.value} + ) + + payload = json.dumps(export_config_dict) response = self.client.make_request( "POST", @@ -496,6 +498,37 @@ def create_local_export(self, export_config): report_id = response.get("response", {}).get("report_id") return Export(report_id=report_id, project=self) + def list_exports(self): + """ + Lists all exports for the project. + + :return: ExportsListResponse + :raises LabellerrError: If the request fails + """ + request_uuid = client_utils.generate_request_id() + url = f"{constants.BASE_URL}/exports/list?project_id={self.project_id}&uuid={request_uuid}&client_id={self.client.client_id}" + + response = self.client.make_request( + "GET", + url, + request_id=request_uuid, + ) + if not response: + raise LabellerrError( + f"No response received from list exports API (request_id: {request_uuid})" + ) + if "response" not in response: + error_msg = response.get("error", "Unknown error") + raise LabellerrError( + f"List exports API failed: {error_msg} (request_id: {request_uuid})" + ) + response_data = response.get("response", {}) + + return schemas.ExportsListResponse( + completed=response_data.get("completed", []), + in_progress=response_data.get("inProgress", []), + ) + def check_export_status(self, report_ids: list[str]): request_uuid = client_utils.generate_request_id() try: @@ -516,22 +549,21 @@ def check_export_status(self, report_ids: list[str]): ) # Now process each report_id - for status_item in result.get("status", []): + for idx, status_item in enumerate(result.get("status", [])): if ( status_item.get("is_completed") and status_item.get("export_status") == "Created" ): # Download URL if job completed - download_url = ( # noqa E999 todo check use of that - self.__fetch_exports_download_url( - project_id=self.project_id, - uuid=request_uuid, - export_id=status_item["report_id"], - client_id=self.client.client_id, - ) + download_url = self.__fetch_exports_download_url( + project_id=self.project_id, + uuid=request_uuid, + export_id=status_item["report_id"], + client_id=self.client.client_id, ) + result["status"][idx]["download_url"] = download_url - return json.dumps(result, indent=2) + return result except requests.exceptions.RequestException as e: logging.error(f"Failed to check export status: {str(e)}") diff --git a/labellerr/core/schemas/__init__.py b/labellerr/core/schemas/__init__.py index 5579571..1839463 100644 --- a/labellerr/core/schemas/__init__.py +++ b/labellerr/core/schemas/__init__.py @@ -73,7 +73,11 @@ ) # Export schemas -from labellerr.core.schemas.exports import CreateExportParams, ExportDestination +from labellerr.core.schemas.exports import ( + CreateExportParams, + ExportDestination, + ExportsListResponse, +) # Export annotation templates @@ -132,6 +136,7 @@ # Export schemas "CreateExportParams", "ExportDestination", + "ExportsListResponse", # Annotation templates schemas "AnnotationQuestion", "Option", diff --git a/labellerr/core/schemas/exports.py b/labellerr/core/schemas/exports.py index 05a842d..a2fad0d 100644 --- a/labellerr/core/schemas/exports.py +++ b/labellerr/core/schemas/exports.py @@ -1,5 +1,5 @@ +from typing import Dict, List, Optional from pydantic import BaseModel, Field -from typing import List, Optional from enum import Enum @@ -15,6 +15,16 @@ class CreateExportParams(BaseModel): export_description: str = Field(min_length=1) export_format: str = Field(min_length=1) statuses: List[str] = Field(min_length=1) + question_ids: List[str] = Field(default=["all"]) export_destination: ExportDestination = Field(default=ExportDestination.LOCAL) connection_id: Optional[str] = None export_folder_path: Optional[str] = None + updated_after_timestamp: Optional[int] = Field( + default=None, + description="Unix Timestamp in milliseconds to filter files to be exported based on last updated time", + ) + + +class ExportsListResponse(BaseModel): + completed: List[Dict] = Field(default=[]) + in_progress: List[Dict] = Field(default=[]) diff --git a/labellerr/mcp_server/server.py b/labellerr/mcp_server/server.py index 7ca5985..860ba13 100644 --- a/labellerr/mcp_server/server.py +++ b/labellerr/mcp_server/server.py @@ -699,10 +699,6 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: project.check_export_status, args["export_ids"] ) - # Parse JSON string result if needed - if isinstance(status_result, str): - status_result = json.loads(status_result) - result = status_result elif name == "annotation_download_export": diff --git a/tests/conftest.py b/tests/conftest.py index e02c0a9..96a8e14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,10 +9,10 @@ import tempfile import time from typing import List, Optional - import pytest - +from unittest.mock import PropertyMock, patch from labellerr.client import LabellerrClient +from labellerr.core.projects.image_project import ImageProject class TestConfig: @@ -109,6 +109,25 @@ def client(): return LabellerrClient("test_api_key", "test_api_secret", "test_client_id") +@pytest.fixture +def project(client): + """Create a test project instance for unit testing using proper mocking""" + project_data = { + "project_id": "test_project_id", + "data_type": "image", + "attached_datasets": [], + } + + with patch.object( + ImageProject, "project_id", new_callable=PropertyMock + ) as mock_project_id: + mock_project_id.return_value = "test_project_id" + proj = ImageProject.__new__(ImageProject) + proj.client = client + proj._project_data = project_data + yield proj + + @pytest.fixture def integration_client(test_credentials): """Create a real client for integration testing""" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 91923eb..3f745d7 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -10,7 +10,6 @@ from labellerr.core.exceptions import LabellerrError from labellerr.core.projects import create_project -from labellerr.core.projects.image_project import ImageProject from labellerr.core.users.base import LabellerrUsers from labellerr.core.schemas.projects import CreateProjectParams, RotationConfig from labellerr.core.annotation_templates import LabellerrAnnotationTemplate @@ -18,23 +17,6 @@ from unittest.mock import Mock, patch -@pytest.fixture -def project(client): - """Create a test project instance without making API calls""" - # Create a mock ImageProject instance directly, bypassing the metaclass factory - project_data = { - "project_id": "test_project_id", - "data_type": "image", - "attached_datasets": [], - } - # Use __new__ to create instance without calling __init__ through metaclass - proj = ImageProject.__new__(ImageProject) - proj.client = client - proj._LabellerrProject__project_id_input = "test_project_id" - proj._LabellerrProject__project_data = project_data - return proj - - @pytest.fixture def users(client): """Create a test users instance with client reference""" diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py new file mode 100644 index 0000000..dc6a7e0 --- /dev/null +++ b/tests/unit/test_projects.py @@ -0,0 +1,72 @@ +""" +Unit tests for Labellerr project functionality. +""" + +import pytest +from unittest.mock import patch + +from labellerr.core.exceptions import LabellerrError +from labellerr.core.schemas import ExportsListResponse + + +@pytest.mark.unit +class TestListExports: + """Test cases for list_exports method""" + + def test_list_exports_returns_completed_and_in_progress_exports(self, project): + """Test that list_exports correctly returns both completed and in-progress exports""" + mock_response = { + "response": { + "completed": [{"report_id": "export-123"}], + "inProgress": [{"report_id": "export-456"}], + } + } + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert isinstance(result, ExportsListResponse) + assert len(result.completed) == 1 + assert len(result.in_progress) == 1 + assert result.completed[0]["report_id"] == "export-123" + assert result.in_progress[0]["report_id"] == "export-456" + + def test_list_exports_handles_empty_exports(self, project): + """Test that list_exports handles case when no exports exist""" + mock_response = {"response": {"completed": [], "inProgress": []}} + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert result.completed == [] + assert result.in_progress == [] + + def test_list_exports_raises_error_for_invalid_response(self, project): + """Test that list_exports raises LabellerrError when API returns invalid response""" + with patch.object(project.client, "make_request", return_value=None): + with pytest.raises(LabellerrError) as exc_info: + project.list_exports() + + assert "No response received from list exports API" in str(exc_info.value) + + def test_list_exports_raises_error_when_response_key_missing(self, project): + """Test that list_exports raises LabellerrError when 'response' key is missing""" + mock_response = {"error": "something went wrong"} + + with patch.object(project.client, "make_request", return_value=mock_response): + with pytest.raises(LabellerrError) as exc_info: + project.list_exports() + + assert "List exports API failed: something went wrong" in str( + exc_info.value + ) + + def test_list_exports_handles_missing_optional_fields(self, project): + """Test that list_exports handles response with missing optional fields gracefully""" + mock_response = {"response": {}} + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert result.completed == [] + assert result.in_progress == []