diff --git a/src/asyncplatform/resources/projects.py b/src/asyncplatform/resources/projects.py index e2b0aa9..e1f098f 100644 --- a/src/asyncplatform/resources/projects.py +++ b/src/asyncplatform/resources/projects.py @@ -168,12 +168,13 @@ async def importer( *, members: list[ProjectMember] | None = None, preserve_existing_members: bool = True, + overwrite: bool = False, ) -> dict[str, Any]: """Import a project into the platform with optional member assignments. Imports a project and optionally assigns specified groups or user accounts - as project members. Validates that the project doesn't already exist and - that all specified members exist in the platform before importing. + as project members. By default, validates that the project doesn't already + exist. Can optionally overwrite an existing project. Args: project: Complete project definition including name, description, @@ -185,21 +186,30 @@ async def importer( that were included in the imported project definition. If False, removes all members from the imported project before adding the specified members list + overwrite: If True, overwrites the project if it already exists in + the target environment. If False (default), raises an error if + the project already exists Returns: The imported project data including _id, name, and complete configuration from the initial import response Raises: - AsyncPlatformError: If a project with the same name already exists, - or if any specified member (group or account) does not exist, - or if a member has an invalid type + AsyncPlatformError: If overwrite is False and a project with the + same name already exists, or if any specified member (group or + account) does not exist, or if a member has an invalid type HTTPError: If the import or patch operations fail """ project = copy.deepcopy(project) - # Ensure project is new - await self._ensure_project_is_new(project["name"]) + # Check if project exists and handle based on overwrite flag + if not overwrite: + await self._ensure_project_is_new(project["name"]) + else: + # Delete existing project if overwrite is True + existing_projects = await self.studio.find_projects(name=project["name"]) + if existing_projects: + await self.studio.delete_project(existing_projects[0]["_id"]) # Import the project result = await self.studio.import_project(project) diff --git a/src/asyncplatform/services/help.py b/src/asyncplatform/services/help.py index 82945b1..293109d 100644 --- a/src/asyncplatform/services/help.py +++ b/src/asyncplatform/services/help.py @@ -4,8 +4,6 @@ from __future__ import annotations -import asyncio - from typing import Any from asyncplatform import logging @@ -13,12 +11,10 @@ class Service(ServiceBase): - name: str = "help" @logging.trace - async def get_openapi(self, url=None) -> dict[str, Any]: - """ - """ + async def get_openapi(self, url: str | None = None) -> dict[str, Any]: + """ """ res = await self.get("/help/openapi", params={"url": (url or "/")}) return res.json() diff --git a/src/asyncplatform/services/lifecycle_manager.py b/src/asyncplatform/services/lifecycle_manager.py index fab7890..c645266 100644 --- a/src/asyncplatform/services/lifecycle_manager.py +++ b/src/asyncplatform/services/lifecycle_manager.py @@ -79,7 +79,10 @@ async def _fetch_all_paginated( return results tasks = [ - self.get(path, params={"limit": min(limit, total - skip), "skip": skip, **filters}) + self.get( + path, + params={"limit": min(limit, total - skip), "skip": skip, **filters}, + ) for skip in range(limit, total, limit) ] @@ -128,7 +131,9 @@ async def get_action_executions(self, **filters: Any) -> list[dict[str, Any]]: Raises: AsyncPlatformError: If any API request fails during retrieval """ - return await self._fetch_all_paginated("/lifecycle-manager/action-executions", **filters) + return await self._fetch_all_paginated( + "/lifecycle-manager/action-executions", **filters + ) @logging.trace async def get_action_execution(self, execution_id: str) -> dict[str, Any]: @@ -185,7 +190,9 @@ async def get_resources(self, **filters: Any) -> list[dict[str, Any]]: Raises: AsyncPlatformError: If any API request fails during retrieval """ - return await self._fetch_all_paginated("/lifecycle-manager/resources", **filters) + return await self._fetch_all_paginated( + "/lifecycle-manager/resources", **filters + ) @logging.trace async def create_resource(self, resource_data: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/unit/test_resources_projects.py b/tests/unit/test_resources_projects.py index f8bf9d4..21c4e70 100644 --- a/tests/unit/test_resources_projects.py +++ b/tests/unit/test_resources_projects.py @@ -722,6 +722,149 @@ async def test_importer_preserve_existing_members_false_no_new_members(self): # patch_project should not be called since members=None mock_studio.patch_project.assert_not_called() + @pytest.mark.asyncio + async def test_importer_with_overwrite_true_deletes_existing(self): + """Test that overwrite=True deletes existing project before importing. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_studio = MagicMock() + + # Mock finding existing project + mock_studio.find_projects = AsyncMock( + return_value=[{"_id": "existing_proj", "name": "Test Project"}] + ) + # Mock deleting existing project + mock_studio.delete_project = AsyncMock(return_value={"message": "Deleted"}) + # Mock importing new project + mock_studio.import_project = AsyncMock( + return_value={"_id": "new_proj", "name": "Test Project"} + ) + + mock_client.automation_studio = mock_studio + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Updated project"} + + result = await resource.importer(project, overwrite=True) + + # Verify existing project was found + mock_studio.find_projects.assert_called_once_with(name="Test Project") + # Verify existing project was deleted + mock_studio.delete_project.assert_called_once_with("existing_proj") + # Verify new project was imported + mock_studio.import_project.assert_called_once() + assert result["_id"] == "new_proj" + assert result["name"] == "Test Project" + + @pytest.mark.asyncio + async def test_importer_with_overwrite_true_no_existing_project(self): + """Test that overwrite=True works when no existing project exists. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_studio = MagicMock() + + # No existing project found + mock_studio.find_projects = AsyncMock(return_value=[]) + mock_studio.delete_project = AsyncMock() + mock_studio.import_project = AsyncMock( + return_value={"_id": "proj1", "name": "Test Project"} + ) + + mock_client.automation_studio = mock_studio + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "New project"} + + result = await resource.importer(project, overwrite=True) + + # Verify search was performed + mock_studio.find_projects.assert_called_once_with(name="Test Project") + # Verify no deletion occurred since project didn't exist + mock_studio.delete_project.assert_not_called() + # Verify project was imported + mock_studio.import_project.assert_called_once() + assert result["_id"] == "proj1" + + @pytest.mark.asyncio + async def test_importer_with_overwrite_false_raises_on_existing(self): + """Test that overwrite=False raises error when project exists. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_studio = MagicMock() + + mock_studio.find_projects = AsyncMock( + return_value=[{"_id": "existing", "name": "Test Project"}] + ) + mock_client.automation_studio = mock_studio + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test"} + + # Test with explicit overwrite=False + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.importer(project, overwrite=False) + + assert "Project `Test Project` already exists" in str(exc_info.value) + # Verify delete was never called + mock_studio.delete_project.assert_not_called() + + @pytest.mark.asyncio + async def test_importer_overwrite_default_false(self): + """Test that overwrite parameter defaults to False. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_studio = MagicMock() + + mock_studio.find_projects = AsyncMock( + return_value=[{"_id": "existing", "name": "Test Project"}] + ) + mock_client.automation_studio = mock_studio + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test"} + + # Test without specifying overwrite (should default to False) + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.importer(project) + + assert "Project `Test Project` already exists" in str(exc_info.value) + class TestDelete: """Test suite for delete method.""" diff --git a/tests/unit/test_services_lifecycle_manager.py b/tests/unit/test_services_lifecycle_manager.py index 7aa1d52..93f180e 100644 --- a/tests/unit/test_services_lifecycle_manager.py +++ b/tests/unit/test_services_lifecycle_manager.py @@ -149,7 +149,10 @@ async def test_fetch_all_paginated_propagates_exception(self, mock_get): async def test_fetch_all_paginated_passes_filters(self, mock_get): """Test _fetch_all_paginated passes filter parameters correctly.""" mock_response = Mock() - mock_response.json.return_value = {"metadata": {"total": 1}, "data": [{"id": "1"}]} + mock_response.json.return_value = { + "metadata": {"total": 1}, + "data": [{"id": "1"}], + } mock_get.return_value = mock_response ctx = context.Context() @@ -367,7 +370,9 @@ async def test_edit_resource_returns_edited_data(self): ctx.client = mock_client service = Service(ctx) - edits = {"operations": [{"op": "replace", "path": "/name", "value": "EditedDevice"}]} + edits = { + "operations": [{"op": "replace", "path": "/name", "value": "EditedDevice"}] + } result = await service.edit_resource("resource123", edits)