diff --git a/src/galileo/__future__/dataset.py b/src/galileo/__future__/dataset.py index 8398f398..9943e9cd 100644 --- a/src/galileo/__future__/dataset.py +++ b/src/galileo/__future__/dataset.py @@ -526,8 +526,9 @@ def save(self) -> Dataset: """ Save changes to this dataset. - This method is a placeholder for future functionality to update - dataset properties. + Persists any local modifications to the API. If the dataset has not been + created yet (LOCAL_ONLY state), this calls create() instead. If already + synced with no pending changes, this is a no-op. Returns ------- @@ -535,9 +536,45 @@ def save(self) -> Dataset: Raises ------ - NotImplementedError: This functionality is not yet implemented. + ValueError: If the dataset has been deleted or has no ID set. + Exception: If the API call fails. + + Examples + -------- + # Create a new dataset + dataset = Dataset(name="my-dataset") + dataset.save() + + # Update an existing dataset's name + dataset = Dataset.get(name="my-dataset") + dataset.name = "renamed-dataset" + dataset.save() """ - raise NotImplementedError( - "Dataset updates are not yet implemented. " - "Use add_rows() to add content or other specific methods to modify dataset state." - ) + if self.sync_state == SyncState.LOCAL_ONLY: + return self.create() + if self.sync_state == SyncState.SYNCED: + logger.debug(f"Dataset.save: id='{self.id}' - already synced, no action needed") + return self + if self.sync_state == SyncState.DELETED: + raise ValueError("Cannot save a deleted dataset.") + + # DIRTY or FAILED_SYNC + if self.id is None: + raise ValueError("Dataset ID is not set. Cannot update a dataset without an ID.") + + try: + logger.info(f"Dataset.save: name='{self.name}' id='{self.id}' - started") + datasets_service = Datasets() + updated_dataset = datasets_service.update(self.id, name=self.name) + + # Update attributes from response + self.name = updated_dataset.name + self.updated_at = updated_dataset.updated_at + + self._set_state(SyncState.SYNCED) + logger.info(f"Dataset.save: id='{self.id}' - completed") + return self + except Exception as e: + self._set_state(SyncState.FAILED_SYNC, error=e) + logger.error(f"Dataset.save: id='{self.id}' - failed: {e}") + raise diff --git a/src/galileo/__future__/project.py b/src/galileo/__future__/project.py index 2f919b5f..e82fdd06 100644 --- a/src/galileo/__future__/project.py +++ b/src/galileo/__future__/project.py @@ -760,8 +760,9 @@ def save(self) -> Project: """ Save changes to this project. - This method is a placeholder for future functionality to update - project properties. + Persists any local modifications to the API. If the project has not been + created yet (LOCAL_ONLY state), this calls create() instead. If already + synced with no pending changes, this is a no-op. Returns ------- @@ -769,11 +770,46 @@ def save(self) -> Project: Raises ------ - NotImplementedError: This functionality is not yet implemented. - """ - raise NotImplementedError( - "Project updates are not yet implemented. Use specific methods to modify project state." - ) + ValueError: If the project has been deleted or has no ID set. + Exception: If the API call fails. + + Examples + -------- + # Create a new project + project = Project(name="My Project") + project.save() + + # Update an existing project's name + project = Project.get(name="My Project") + project.name = "Renamed Project" + project.save() + """ + if self.sync_state == SyncState.LOCAL_ONLY: + return self.create() + if self.sync_state == SyncState.SYNCED: + logger.debug(f"Project.save: id='{self.id}' - already synced, no action needed") + return self + if self.sync_state == SyncState.DELETED: + raise ValueError("Cannot save a deleted project.") + + if self.id is None: + raise ValueError("Project ID is not set. Cannot update a project without an ID.") + + try: + logger.info(f"Project.save: name='{self.name}' id='{self.id}' - started") + projects_service = Projects() + updated_project = projects_service.update(self.id, name=self.name) + + self.name = updated_project.name + self.updated_at = updated_project.updated_at + + self._set_state(SyncState.SYNCED) + logger.info(f"Project.save: id='{self.id}' - completed") + return self + except Exception as e: + self._set_state(SyncState.FAILED_SYNC, error=e) + logger.error(f"Project.save: id='{self.id}' - failed: {e}") + raise # Import at end to avoid circular import (log_stream.py imports Project) diff --git a/src/galileo/datasets.py b/src/galileo/datasets.py index f058e7e1..d205add3 100644 --- a/src/galileo/datasets.py +++ b/src/galileo/datasets.py @@ -17,6 +17,7 @@ query_dataset_versions_datasets_dataset_id_versions_query_post, query_datasets_datasets_query_post, update_dataset_content_datasets_dataset_id_content_patch, + update_dataset_datasets_dataset_id_patch, ) from galileo.resources.models import DatasetRow, ListDatasetVersionParams, ListDatasetVersionResponse from galileo.resources.models.body_create_dataset_datasets_post import BodyCreateDatasetDatasetsPost @@ -38,6 +39,7 @@ from galileo.resources.models.synthetic_dataset_extension_request import SyntheticDatasetExtensionRequest from galileo.resources.models.synthetic_dataset_extension_response import SyntheticDatasetExtensionResponse from galileo.resources.models.update_dataset_content_request import UpdateDatasetContentRequest +from galileo.resources.models.update_dataset_request import UpdateDatasetRequest from galileo.resources.types import UNSET, File, Unset from galileo.schema.datasets import DatasetRecord from galileo.utils.datasets import validate_dataset_in_project @@ -420,6 +422,54 @@ def delete( return delete_dataset_datasets_dataset_id_delete.sync(client=self.config.api_client, dataset_id=dataset.id) + def update(self, dataset_id: str, *, name: Optional[str] = None) -> Dataset: + """ + Updates a dataset's properties. + + Parameters + ---------- + dataset_id : str + The ID of the dataset to update. + name : str, optional + The new name for the dataset. + + Returns + ------- + Dataset + The updated dataset. + + Raises + ------ + DatasetAPIException + If the API request fails. + ValueError + If the server returns no response. + """ + logger.info("Datasets.update: dataset_id=%s name=%s - started", dataset_id, name) + + body = UpdateDatasetRequest(name=name) + + detailed_response = update_dataset_datasets_dataset_id_patch.sync_detailed( + dataset_id=dataset_id, client=self.config.api_client, body=body + ) + + if detailed_response.status_code != 200: + logger.error("Datasets.update: dataset_id=%s status=%s - failed", dataset_id, detailed_response.status_code) + raise DatasetAPIException(detailed_response.content) + + response = detailed_response.parsed + + if isinstance(response, HTTPValidationError): + logger.error("Datasets.update: dataset_id=%s validation_error=%s - failed", dataset_id, response.detail) + raise DatasetAPIException(response.detail) + + if not response: + logger.error("Datasets.update: dataset_id=%s - failed: empty response", dataset_id) + raise ValueError(f"Unable to update dataset: {dataset_id}") + + logger.info("Datasets.update: dataset_id=%s - completed", dataset_id) + return Dataset(dataset_db=response) + def create( self, name: str, content: DatasetType, *, project_id: Optional[str] = None, project_name: Optional[str] = None ) -> Dataset: diff --git a/src/galileo/projects.py b/src/galileo/projects.py index 7a33688f..65628b19 100644 --- a/src/galileo/projects.py +++ b/src/galileo/projects.py @@ -14,6 +14,7 @@ get_project_projects_project_id_get, get_projects_projects_get, list_user_project_collaborators_projects_project_id_users_get, + update_project_projects_project_id_put, update_user_project_collaborator_projects_project_id_users_user_id_patch, ) from galileo.resources.models.collaborator_role import CollaboratorRole @@ -25,6 +26,8 @@ from galileo.resources.models.project_db import ProjectDB from galileo.resources.models.project_db_thin import ProjectDBThin from galileo.resources.models.project_type import ProjectType +from galileo.resources.models.project_update import ProjectUpdate +from galileo.resources.models.project_update_response import ProjectUpdateResponse from galileo.resources.models.user_collaborator import UserCollaborator from galileo.resources.models.user_collaborator_create import UserCollaboratorCreate from galileo.resources.types import UNSET, Unset @@ -101,13 +104,15 @@ class Project: permissions: Union[Unset, list["Permission"]] = UNSET type: Union[None, ProjectType, Unset] = UNSET - def __init__(self, project: Union[None, ProjectDBThin, ProjectDB, ProjectCreateResponse] = None) -> None: + def __init__( + self, project: Union[None, ProjectDBThin, ProjectDB, ProjectCreateResponse, ProjectUpdateResponse] = None + ) -> None: """ Initialize a Project instance. Parameters ---------- - project (Union[None, ProjectDBThin, ProjectDB, ProjectCreateResponse], optional): + project : Union[None, ProjectDBThin, ProjectDB, ProjectCreateResponse, ProjectUpdateResponse], optional The project data to initialize from. If None, creates an empty project instance. Defaults to None. """ @@ -297,6 +302,56 @@ def create(self, name: str) -> Project: return Project(project=response) + def update(self, project_id: str, *, name: Optional[str] = None) -> Project: + """ + Updates a project's properties. + + Parameters + ---------- + project_id : str + The ID of the project to update. + name : str, optional + The new name for the project. + + Returns + ------- + Project + The updated project. + + Raises + ------ + ProjectsAPIException + If the server returns an error response. + ValueError + If the server returns no response. + """ + _logger.info("Projects.update: project_id=%s name=%s - started", project_id, name) + + body = ProjectUpdate(name=name) + + detailed_response = update_project_projects_project_id_put.sync_detailed( + project_id=project_id, client=self.config.api_client, body=body + ) + + if detailed_response.status_code != httpx.codes.OK: + _logger.error( + "Projects.update: project_id=%s status=%s - failed", project_id, detailed_response.status_code + ) + raise ProjectsAPIException(detailed_response.content) + + response = detailed_response.parsed + + if isinstance(response, HTTPValidationError): + _logger.error("Projects.update: project_id=%s validation_error=%s - failed", project_id, response.detail) + raise ProjectsAPIException(f"Failed to update project: {response.detail}") + + if not response: + _logger.error("Projects.update: project_id=%s - failed: empty response", project_id) + raise ValueError(f"Unable to update project: {project_id}") + + _logger.info("Projects.update: project_id=%s - completed", project_id) + return Project(project=response) + def share_project_with_user( self, project_id: str, user_id: str, role: CollaboratorRole = CollaboratorRole.VIEWER ) -> UserCollaborator: diff --git a/tests/future/test_dataset.py b/tests/future/test_dataset.py index 20dd0dec..22829a95 100644 --- a/tests/future/test_dataset.py +++ b/tests/future/test_dataset.py @@ -261,16 +261,115 @@ def test_refresh_raises_error_for_local_only(self, reset_configuration: None) -> dataset.refresh() -class TestDatasetMethods: - """Test suite for other Dataset methods.""" +class TestDatasetSave: + """Test suite for Dataset.save() method.""" - def test_save_raises_not_implemented_error(self, reset_configuration: None) -> None: - """Test save() raises NotImplementedError.""" + @patch("galileo.__future__.dataset.Datasets") + def test_save_local_only_delegates_to_create( + self, mock_datasets_class: MagicMock, reset_configuration: None, mock_dataset: MagicMock + ) -> None: + # Given: a local-only dataset and a mocked create response + mock_service = MagicMock() + mock_datasets_class.return_value = mock_service + mock_service.create.return_value = mock_dataset + + # When: save() is called on a LOCAL_ONLY dataset dataset = Dataset(name="Test Dataset") + result = dataset.save() + + # Then: create() is called and the dataset is synced + mock_service.create.assert_called_once_with(name="Test Dataset", content=[]) + assert result.id == mock_dataset.id + assert result.is_synced() + + @patch("galileo.__future__.dataset.Datasets") + def test_save_synced_is_noop( + self, mock_datasets_class: MagicMock, reset_configuration: None, mock_dataset: MagicMock + ) -> None: + # Given: a synced dataset + mock_service = MagicMock() + mock_datasets_class.return_value = mock_service + mock_service.get.return_value = mock_dataset + + dataset = Dataset.get(id=mock_dataset.id) + assert dataset.is_synced() + + # When: save() is called + result = dataset.save() + + # Then: no API update call is made and dataset remains synced + mock_service.update.assert_not_called() + assert result is dataset + assert result.is_synced() + + def test_save_deleted_raises_value_error(self, reset_configuration: None) -> None: + # Given: a dataset in DELETED state + dataset = Dataset(name="Test Dataset") + dataset.id = str(uuid4()) + dataset._set_state(SyncState.DELETED) + + # When/Then: save() raises ValueError + with pytest.raises(ValueError, match="Cannot save a deleted dataset"): + dataset.save() + + def test_save_without_id_raises_value_error(self, reset_configuration: None) -> None: + # Given: a dataset in DIRTY state with no ID + dataset = Dataset(name="Test Dataset") + dataset._set_state(SyncState.DIRTY) + + # When/Then: save() raises ValueError because there is no ID to update + with pytest.raises(ValueError, match="Dataset ID is not set"): + dataset.save() - with pytest.raises(NotImplementedError, match="not yet implemented"): + @patch("galileo.__future__.dataset.Datasets") + def test_save_dirty_calls_update_and_syncs_attributes( + self, mock_datasets_class: MagicMock, reset_configuration: None, mock_dataset: MagicMock + ) -> None: + # Given: a synced dataset that has been renamed and marked dirty + mock_service = MagicMock() + mock_datasets_class.return_value = mock_service + mock_service.get.return_value = mock_dataset + + updated_response = MagicMock() + updated_response.configure_mock(name="Renamed Dataset") + updated_response.updated_at = MagicMock() + mock_service.update.return_value = updated_response + + dataset = Dataset.get(id=mock_dataset.id) + dataset.name = "Renamed Dataset" + dataset._set_state(SyncState.DIRTY) + + # When: save() is called + result = dataset.save() + + # Then: update is called with the new name and state is synced + mock_service.update.assert_called_once_with(mock_dataset.id, name="Renamed Dataset") + assert result.name == "Renamed Dataset" + assert result.is_synced() + + @patch("galileo.__future__.dataset.Datasets") + def test_save_handles_api_failure( + self, mock_datasets_class: MagicMock, reset_configuration: None, mock_dataset: MagicMock + ) -> None: + # Given: a dirty dataset and an API that raises an error + mock_service = MagicMock() + mock_datasets_class.return_value = mock_service + mock_service.get.return_value = mock_dataset + mock_service.update.side_effect = RuntimeError("API error") + + dataset = Dataset.get(id=mock_dataset.id) + dataset._set_state(SyncState.DIRTY) + + # When/Then: the exception propagates and state is FAILED_SYNC + with pytest.raises(RuntimeError, match="API error"): dataset.save() + assert dataset.sync_state == SyncState.FAILED_SYNC + + +class TestDatasetMethods: + """Test suite for other Dataset methods.""" + def test_str_and_repr(self, reset_configuration: None) -> None: """Test __str__ and __repr__ return expected formats.""" dataset = Dataset(name="Test Dataset") diff --git a/tests/future/test_project.py b/tests/future/test_project.py index 7295a88d..3e8cb09c 100644 --- a/tests/future/test_project.py +++ b/tests/future/test_project.py @@ -285,16 +285,115 @@ def test_refresh_raises_error_if_project_no_longer_exists( assert project.sync_state == SyncState.FAILED_SYNC -class TestProjectMethods: - """Test suite for other Project methods.""" +class TestProjectSave: + """Test suite for Project.save() method.""" + + @patch("galileo.__future__.project.Projects") + def test_save_local_only_delegates_to_create( + self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock + ) -> None: + # Given: a local-only project and a mocked create response + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.create.return_value = mock_project + + # When: save() is called on a LOCAL_ONLY project + project = Project(name="Test Project") + result = project.save() + + # Then: create() is called and the project is synced + mock_service.create.assert_called_once_with(name="Test Project") + assert result.id == mock_project.id + assert result.is_synced() + + @patch("galileo.__future__.project.Projects") + def test_save_synced_is_noop( + self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock + ) -> None: + # Given: a synced project + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.return_value = mock_project + + project = Project.get(id=mock_project.id) + assert project.is_synced() - def test_save_raises_not_implemented_error(self, reset_configuration: None) -> None: - """Test save() raises NotImplementedError.""" + # When: save() is called + result = project.save() + + # Then: no API update call is made and project remains synced + mock_service.update.assert_not_called() + assert result is project + assert result.is_synced() + + def test_save_deleted_raises_value_error(self, reset_configuration: None) -> None: + # Given: a project in DELETED state project = Project(name="Test Project") + project.id = str(uuid4()) + project._set_state(SyncState.DELETED) + + # When/Then: save() raises ValueError + with pytest.raises(ValueError, match="Cannot save a deleted project"): + project.save() - with pytest.raises(NotImplementedError, match="not yet implemented"): + def test_save_without_id_raises_value_error(self, reset_configuration: None) -> None: + # Given: a project in DIRTY state but with no ID + project = Project(name="Test Project") + project._set_state(SyncState.DIRTY) + + # When/Then: save() raises ValueError because there is no ID to update + with pytest.raises(ValueError, match="Project ID is not set"): project.save() + @patch("galileo.__future__.project.Projects") + def test_save_dirty_calls_update_and_syncs_attributes( + self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock + ) -> None: + # Given: a synced project that has been renamed and marked dirty + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.return_value = mock_project + + updated_response = MagicMock() + updated_response.configure_mock(name="Renamed Project") + updated_response.updated_at = MagicMock() + mock_service.update.return_value = updated_response + + project = Project.get(id=mock_project.id) + project.name = "Renamed Project" + project._set_state(SyncState.DIRTY) + + # When: save() is called + result = project.save() + + # Then: update is called with the new name and state is synced + mock_service.update.assert_called_once_with(mock_project.id, name="Renamed Project") + assert result.name == "Renamed Project" + assert result.is_synced() + + @patch("galileo.__future__.project.Projects") + def test_save_handles_api_failure( + self, mock_projects_class: MagicMock, reset_configuration: None, mock_project: MagicMock + ) -> None: + # Given: a dirty project and an API that raises an error + mock_service = MagicMock() + mock_projects_class.return_value = mock_service + mock_service.get.return_value = mock_project + mock_service.update.side_effect = RuntimeError("API error") + + project = Project.get(id=mock_project.id) + project._set_state(SyncState.DIRTY) + + # When/Then: the exception propagates and state is FAILED_SYNC + with pytest.raises(RuntimeError, match="API error"): + project.save() + + assert project.sync_state == SyncState.FAILED_SYNC + + +class TestProjectMethods: + """Test suite for other Project methods.""" + def test_str_representation(self, reset_configuration: None) -> None: """Test __str__ returns expected format.""" project = Project(name="Test Project")