From 3f7819b9811aa8bdbeb9ffc8d2d39384dc4b6f32 Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Tue, 27 Oct 2026 14:56:43 +1300 Subject: [PATCH 1/3] DM-4391 feat(projects): add update_project and has_git_synced_resources --- HISTORY.rst | 5 ++ datamasque/client/__init__.py | 2 + datamasque/client/models/project.py | 22 ++++++ datamasque/client/projects.py | 25 +++++++ tests/test_projects.py | 106 +++++++++++++++++++++++++++- 5 files changed, 159 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index d6db351..828fd65 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -48,6 +48,11 @@ History putting their items that name no project into ``fallback_project``, else the server's earliest-created active project; ``default_project`` does not apply. Both require server version 3.26.21. +* Added ``update_project``, which takes a ``ProjectUpdateRequest`` to rename a project or change its description, + and returns the updated ``Project``. + Only admin users can update projects, and API tokens cannot. +* ``get_project`` sets ``Project.has_git_synced_resources``, which says whether the project holds a git-synced resource. + A rename leaves a git-synced library or discovery config library at its old repository path. 1.6.0 (unreleased) ------------------ diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index d49e8f6..5399310 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -133,6 +133,7 @@ ProjectDeleteConflictReason, ProjectId, ProjectIdentifier, + ProjectUpdateRequest, ) from datamasque.client.models.resource import ( ConnectionResource, @@ -317,6 +318,7 @@ "ProjectDeleteConflictReason", "ProjectId", "ProjectIdentifier", + "ProjectUpdateRequest", "RGConfig", "RGConfigId", "RGConfigNotFoundError", diff --git a/datamasque/client/models/project.py b/datamasque/client/models/project.py index c925f03..b295b45 100644 --- a/datamasque/client/models/project.py +++ b/datamasque/client/models/project.py @@ -37,6 +37,14 @@ class Project(BaseModel): description: str = "" created: datetime updated: datetime + has_git_synced_resources: Optional[bool] = None + """ + Whether any library, discovery config library, ruleset or discovery config in the project + that is not deleted is synced with git. + + Only `get_project` sets it; other calls leave it `None`. + A rename leaves a git-synced library or discovery config library at its old repository path. + """ # A project, given as a `Project`, its `ProjectId`, or its `-` form such as a listed resource's `project`. @@ -80,6 +88,20 @@ class ProjectCreateRequest(BaseModel): description: str = "" +class ProjectUpdateRequest(BaseModel): + """ + Request body for `PATCH /api/projects/{id}/`. + + A field left as `None` is not sent, so the server keeps its current value. + A new `name` follows the rules of `ProjectCreateRequest`. + """ + + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + description: Optional[str] = None + + class ProjectDeleteConflictReason(str, Enum): """Why a project's resources cannot move to the destination project when the project is deleted.""" diff --git a/datamasque/client/projects.py b/datamasque/client/projects.py index ce03ede..ba40839 100644 --- a/datamasque/client/projects.py +++ b/datamasque/client/projects.py @@ -10,6 +10,7 @@ ProjectCreateRequest, ProjectDeleteConflict, ProjectIdentifier, + ProjectUpdateRequest, unwrap_project_id, ) @@ -66,6 +67,30 @@ def create_project(self, request: ProjectCreateRequest) -> Project: logger.info('Creation of project "%s" successful', project.name) return project + def update_project(self, project: ProjectIdentifier, request: ProjectUpdateRequest) -> Project: + """ + Renames a project or changes its description, and returns the updated project. + + Only admin users can, and API tokens cannot. + Renaming moves the project's seed and credential files to the folder of the new name, + and invalidates rulesets, discovery configs and generator configs that import a library by its old name. + A rename that only changes the case of the name moves no files, and a file outside the project's folder stays. + Renaming the Default project invalidates imports that name its libraries without a project name. + Seed and table references in rulesets that name the old project are not rewritten. + + Raises: + DataMasqueApiError: the server rejects the name (see `ProjectCreateRequest`), + a file already exists where one of the project's files would move, + the project does not exist or is deleted, or any other non-2xx response. + """ + + project_id = unwrap_project_id(project) + data = request.model_dump(mode="json", exclude_none=True) + response = self.make_request("PATCH", f"/api/projects/{project_id}/", data=data) + updated_project = Project.model_validate(response.json()) + logger.info("Update of project %s successful", project_id) + return updated_project + def get_project_delete_conflicts( self, project: ProjectIdentifier, diff --git a/tests/test_projects.py b/tests/test_projects.py index ff94b7a..11c3a96 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -1,7 +1,7 @@ """Tests for project support in the DataMasque client.""" from datetime import datetime -from typing import Callable +from typing import Callable, Optional import pytest import requests_mock @@ -17,6 +17,7 @@ ProjectDeleteConflictReason, ProjectId, ProjectIdentifier, + ProjectUpdateRequest, ) from datamasque.client.exceptions import DataMasqueException from datamasque.client.models.connection import ConnectionId, S3ConnectionConfig @@ -49,6 +50,28 @@ "updated": "2026-10-01T10:00:00Z", } +FILES_EXIST_MESSAGE = 'Cannot rename the project, because files already exist at "seeds/retail".' + +RENAMED_PROJECT = {**BANKING_PROJECT, "reference": "7-retail", "name": "Retail"} + +UPDATE_PROJECT_CASES = [ + pytest.param(ProjectId(7), ProjectUpdateRequest(name="Retail"), {"name": "Retail"}, id="rename-by-id"), + pytest.param("7-banking", ProjectUpdateRequest(name="Retail"), {"name": "Retail"}, id="rename-by-reference"), + pytest.param( + Project.model_validate(BANKING_PROJECT), + ProjectUpdateRequest(description=""), + {"description": ""}, + id="project-object-clears-description", + ), + pytest.param( + ProjectId(7), + ProjectUpdateRequest(name="Banking", description="Customer banking data"), + {"name": "Banking", "description": "Customer banking data"}, + id="name-and-description", + ), +] + + CONFLICTS = [ { "reason": "name_in_use", @@ -432,3 +455,84 @@ def test_unwrap_project_id_reads_the_id_from_each_form(project: ProjectIdentifie def test_unwrap_project_id_rejects_a_string_that_does_not_start_with_an_id(project: str) -> None: with pytest.raises(ValueError, match=f'"{project}" is not a project ID or reference'): unwrap_project_id(project) + + +def test_update_request_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError) as exc_info: + # mypy rejects the unknown field, which the test passes on purpose. + ProjectUpdateRequest(name="Banking", archived=True) # type: ignore[call-arg] + + assert [(error["loc"], error["msg"]) for error in exc_info.value.errors()] == [ + (("archived",), "Extra inputs are not permitted") + ] + + +@pytest.mark.parametrize(("project", "request_body", "expected_body"), UPDATE_PROJECT_CASES) +def test_update_project_sends_only_the_fields_set( + client: DataMasqueClient, + project: ProjectIdentifier, + request_body: ProjectUpdateRequest, + expected_body: dict[str, object], +) -> None: + with requests_mock.Mocker() as m: + m.patch("http://test-server/api/projects/7/", json=BANKING_PROJECT) + client.update_project(project, request_body) + + assert m.last_request.json() == expected_body + + +def test_update_project_returns_the_renamed_project(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.patch("http://test-server/api/projects/7/", json=RENAMED_PROJECT) + project = client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail")) + + assert project.name == "Retail" + assert project.reference == "7-retail" + + +def test_update_project_rejected_name_raises(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.patch( + "http://test-server/api/projects/7/", + json={"name": [FILES_EXIST_MESSAGE]}, + status_code=400, + ) + with pytest.raises(DataMasqueApiError) as exc_info: + client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail")) + + assert exc_info.value.response.status_code == 400 + assert exc_info.value.response.json() == {"name": [FILES_EXIST_MESSAGE]} + + +@pytest.mark.parametrize( + ("status_code", "detail"), + [ + (403, "You do not have permission to perform this action."), + (404, "Not found."), + ], +) +def test_update_project_refused_raises(client: DataMasqueClient, status_code: int, detail: str) -> None: + with requests_mock.Mocker() as m: + m.patch("http://test-server/api/projects/7/", json={"detail": detail}, status_code=status_code) + with pytest.raises(DataMasqueApiError) as exc_info: + client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail")) + + assert exc_info.value.response.status_code == status_code + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + pytest.param({**BANKING_PROJECT, "has_git_synced_resources": True}, True, id="synced"), + pytest.param({**BANKING_PROJECT, "has_git_synced_resources": False}, False, id="not-synced"), + pytest.param(BANKING_PROJECT, None, id="flag-absent"), + ], +) +def test_get_project_reports_git_synced_resources( + client: DataMasqueClient, body: dict[str, object], expected: Optional[bool] +) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/projects/7/", json=body) + project = client.get_project(ProjectId(7)) + + assert project.has_git_synced_resources is expected From ca519bbb08e5db5be5aed9ef4d40327fe518de2b Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Thu, 29 Oct 2026 12:48:44 +1300 Subject: [PATCH 2/3] DM-4391 docs(projects): match the update_project and git flag docs to the server wording --- HISTORY.rst | 2 +- datamasque/client/models/project.py | 4 +++- datamasque/client/projects.py | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 828fd65..2d1ec91 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -52,7 +52,7 @@ History and returns the updated ``Project``. Only admin users can update projects, and API tokens cannot. * ``get_project`` sets ``Project.has_git_synced_resources``, which says whether the project holds a git-synced resource. - A rename leaves a git-synced library or discovery config library at its old repository path. + After a rename, a git-synced resource's next push writes a new copy under the new name, and the old copy stays. 1.6.0 (unreleased) ------------------ diff --git a/datamasque/client/models/project.py b/datamasque/client/models/project.py index b295b45..cd450bd 100644 --- a/datamasque/client/models/project.py +++ b/datamasque/client/models/project.py @@ -42,8 +42,10 @@ class Project(BaseModel): Whether any library, discovery config library, ruleset or discovery config in the project that is not deleted is synced with git. + A resource edited since its last push is not counted. Only `get_project` sets it; other calls leave it `None`. - A rename leaves a git-synced library or discovery config library at its old repository path. + After a rename, a git-synced resource's next push writes a new copy under the new name and the old copy stays. + DataMasque does not move files in the repository. """ diff --git a/datamasque/client/projects.py b/datamasque/client/projects.py index ba40839..ef64906 100644 --- a/datamasque/client/projects.py +++ b/datamasque/client/projects.py @@ -72,10 +72,9 @@ def update_project(self, project: ProjectIdentifier, request: ProjectUpdateReque Renames a project or changes its description, and returns the updated project. Only admin users can, and API tokens cannot. - Renaming moves the project's seed and credential files to the folder of the new name, - and invalidates rulesets, discovery configs and generator configs that import a library by its old name. - A rename that only changes the case of the name moves no files, and a file outside the project's folder stays. - Renaming the Default project invalidates imports that name its libraries without a project name. + Renaming invalidates rulesets, discovery configs and generator configs that import a library of the project + by the old project name. Imports without a project name stay valid. + It also moves the project's seed and credential files, keeping connections working. Seed and table references in rulesets that name the old project are not rewritten. Raises: From 230179a2df8963c08c2ebf88a371d35f09fda06e Mon Sep 17 00:00:00 2001 From: jiatolentino Date: Tue, 3 Nov 2026 10:58:37 +0800 Subject: [PATCH 3/3] docs(projects): a project name only has to differ from active projects' names --- datamasque/client/models/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datamasque/client/models/project.py b/datamasque/client/models/project.py index cd450bd..fc32cb2 100644 --- a/datamasque/client/models/project.py +++ b/datamasque/client/models/project.py @@ -80,7 +80,7 @@ class ProjectCreateRequest(BaseModel): Request body for `POST /api/projects/`. A name must not contain "/", ".." or a control character, - and must differ, ignoring case, from the name of every other project, including deleted ones. + and must differ, ignoring case, from the name of every other active project. The server trims leading and trailing whitespace from `name` and `description`. """