From 2c30b35c474e6dc69d36c28efebef48c4e26b631 Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Fri, 16 Oct 2026 12:24:56 +1300 Subject: [PATCH 1/3] feat: add delete_project and get_project_delete_conflicts --- HISTORY.rst | 3 +- datamasque/client/__init__.py | 12 ++- datamasque/client/exceptions.py | 17 ++++ datamasque/client/models/project.py | 39 +++++++- datamasque/client/projects.py | 69 ++++++++++++++- tests/test_projects.py | 133 +++++++++++++++++++++++++++- 6 files changed, 267 insertions(+), 6 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index b958640..34d8bb6 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,8 +5,7 @@ History 1.7.0 (unreleased) ------------------ -* Added ``create_project``, which takes a ``ProjectCreateRequest`` and returns the created ``Project``. - Only admin users can create projects, and API tokens cannot. +* Added project models and management APIs. 1.6.0 (unreleased) ------------------ diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index 3146bae..27e6fac 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -23,6 +23,7 @@ InvalidDiscoveryConfigError, InvalidLibraryError, InvalidRulesetError, + ProjectDeleteConflictError, RGConfigNotFoundError, RunNotCancellableError, ) @@ -115,7 +116,13 @@ RulesetPlanUpdateRequest, ) from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata -from datamasque.client.models.project import Project, ProjectCreateRequest, ProjectId +from datamasque.client.models.project import ( + Project, + ProjectCreateRequest, + ProjectDeleteConflict, + ProjectDeleteConflictReason, + ProjectId, +) from datamasque.client.models.rg_config import RGConfig, RGConfigId from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId @@ -272,6 +279,9 @@ "PatternsStatistics", "Project", "ProjectCreateRequest", + "ProjectDeleteConflict", + "ProjectDeleteConflictError", + "ProjectDeleteConflictReason", "ProjectId", "RGConfig", "RGConfigId", diff --git a/datamasque/client/exceptions.py b/datamasque/client/exceptions.py index 9943b8b..747fd64 100644 --- a/datamasque/client/exceptions.py +++ b/datamasque/client/exceptions.py @@ -1,5 +1,7 @@ from requests import Response +from datamasque.client.models.project import ProjectDeleteConflict + class DataMasqueException(Exception): """Generic exception base class.""" @@ -66,6 +68,21 @@ class RGConfigNotFoundError(DataMasqueApiError): """ +class ProjectDeleteConflictError(DataMasqueApiError): + """ + Raised when `delete_project` cannot move the project's resources to the destination project. + + The server deletes and moves nothing in this case. + + Attributes: + conflicts: Every reason the resources cannot move. + """ + + def __init__(self, message: str, *, response: Response, conflicts: list[ProjectDeleteConflict]) -> None: + super().__init__(message, response=response) + self.conflicts = conflicts + + class DataMasqueTransportError(DataMasqueException): """ Raised when a request to the DataMasque server fails before any response is received. diff --git a/datamasque/client/models/project.py b/datamasque/client/models/project.py index 40b9bcf..6eedc1b 100644 --- a/datamasque/client/models/project.py +++ b/datamasque/client/models/project.py @@ -1,7 +1,8 @@ """Project models for the DataMasque API.""" from datetime import datetime -from typing import NewType +from enum import Enum +from typing import NewType, Optional, Union from pydantic import BaseModel, ConfigDict @@ -22,6 +23,11 @@ class Project(BaseModel): updated: datetime +def unwrap_project_id(project: Union[ProjectId, Project]) -> ProjectId: + """Return the id of `project`, which a caller may give as a `Project` or as its `ProjectId`.""" + return project.id if isinstance(project, Project) else project + + class ProjectCreateRequest(BaseModel): """ Request body for `POST /api/projects/`. @@ -37,3 +43,34 @@ class ProjectCreateRequest(BaseModel): name: str description: str = "" + + +class ProjectDeleteConflictReason(str, Enum): + """Why a project's resources cannot move to the destination project when the project is deleted.""" + + name_in_use = "name_in_use" + """The destination project already contains a resource of the same type and name.""" + file_exists = "file_exists" + """The destination project already has a file or directory where the resource's files would move to.""" + unexpected_file = "unexpected_file" + """The project's directory contains a file or directory that belongs to none of its resources.""" + + +class ProjectDeleteConflict(BaseModel): + """A conflict that prevents a project's resources moving to the destination project when the project is deleted.""" + + model_config = ConfigDict(extra="allow") + + reason: ProjectDeleteConflictReason + detail: str + """A message that describes the conflict.""" + type: Optional[str] = None + """ + For `name_in_use` and `file_exists`: the type of the resource that would move, such as `connections` or `rulesets`. + """ + id: Optional[str] = None + """For `name_in_use` and `file_exists`: the ID of the resource that would move.""" + name: Optional[str] = None + """For `name_in_use` and `file_exists`: the name of the resource that would move.""" + path: Optional[str] = None + """For `file_exists` and `unexpected_file`: the path of the file or directory.""" diff --git a/datamasque/client/projects.py b/datamasque/client/projects.py index 1175e75..1646b13 100644 --- a/datamasque/client/projects.py +++ b/datamasque/client/projects.py @@ -1,11 +1,25 @@ import logging +from typing import Union + +from requests import Response from datamasque.client.base import BaseClient -from datamasque.client.models.project import Project, ProjectCreateRequest +from datamasque.client.exceptions import ProjectDeleteConflictError +from datamasque.client.models.project import ( + Project, + ProjectCreateRequest, + ProjectDeleteConflict, + ProjectId, + unwrap_project_id, +) logger = logging.getLogger(__name__) +def _get_conflicts(response: Response) -> list[ProjectDeleteConflict]: + return [ProjectDeleteConflict.model_validate(conflict) for conflict in response.json()["conflicts"]] + + class ProjectClient(BaseClient): """Project API methods. Mixed into `DataMasqueClient`.""" @@ -23,3 +37,56 @@ def create_project(self, request: ProjectCreateRequest) -> Project: project = Project.model_validate(response.json()) logger.info('Creation of project "%s" successful', project.name) return project + + def get_project_delete_conflicts( + self, + project: Union[ProjectId, Project], + destination: Union[ProjectId, Project], + ) -> list[ProjectDeleteConflict]: + """ + Returns the conflicts that would prevent `delete_project` moving the project's resources to the destination. + + An empty list means `delete_project` can delete the project with this destination, + unless the resources in either project change first. + + Raises: + DataMasqueApiError: the destination does not exist, is deleted, or is the project; + the project does not exist or is already deleted; or any other non-2xx response. + """ + + response = self.make_request( + "GET", + f"/api/projects/{unwrap_project_id(project)}/delete-check/", + params={"destination": unwrap_project_id(destination)}, + ) + return _get_conflicts(response) + + def delete_project(self, project: Union[ProjectId, Project], destination: Union[ProjectId, Project]) -> None: + """ + Deletes a project, after moving every resource in it to the destination project. + + When the resources cannot move, the server changes nothing. + `get_project_delete_conflicts` lists the same conflicts without trying to delete the project. + + Raises: + ProjectDeleteConflictError: the resources cannot move to the destination; its `conflicts` says why. + DataMasqueApiError: the destination does not exist, is deleted, or is the project; + the project does not exist or is already deleted; or any other non-2xx response. + """ + + project_id = unwrap_project_id(project) + response = self.make_request( + "DELETE", + f"/api/projects/{project_id}/", + params={"destination": unwrap_project_id(destination)}, + require_status_check=False, + ) + if response.status_code == 409: + raise ProjectDeleteConflictError( + str(response.json()["detail"]), + response=response, + conflicts=_get_conflicts(response), + ) + + self._raise_for_status(response) + logger.info("Deletion of project %s successful", project_id) diff --git a/tests/test_projects.py b/tests/test_projects.py index 383f5a1..17131e8 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -6,7 +6,16 @@ import requests_mock from pydantic import ValidationError -from datamasque.client import DataMasqueApiError, DataMasqueClient, ProjectCreateRequest, ProjectId +from datamasque.client import ( + DataMasqueApiError, + DataMasqueClient, + Project, + ProjectCreateRequest, + ProjectDeleteConflict, + ProjectDeleteConflictError, + ProjectDeleteConflictReason, + ProjectId, +) CREATE_RESPONSE = { "id": 7, @@ -17,6 +26,29 @@ "updated": "2026-10-01T10:00:00Z", } +CONFLICTS = [ + { + "reason": "name_in_use", + "detail": 'The destination project already contains a ruleset named "3407".', + "type": "rulesets", + "id": "0b6c2f0e-4a0d-4d8e-9a57-2f0f3c1d9e11", + "name": "3407", + }, + { + "reason": "file_exists", + "detail": 'The destination project already has a file at "projects/8-retail/seeds/names.csv".', + "type": "seeds", + "id": "5e3b8a40-7c2d-4f61-b1a9-0d4e6f2c8b77", + "name": "names.csv", + "path": "projects/8-retail/seeds/names.csv", + }, + { + "reason": "unexpected_file", + "detail": 'The project directory contains "projects/7-banking/notes.txt", which belongs to no resource.', + "path": "projects/7-banking/notes.txt", + }, +] + def test_create_request_rejects_unknown_fields() -> None: with pytest.raises(ValidationError, match="archived"): @@ -63,3 +95,102 @@ def test_create_project_rejected_name_raises(client: DataMasqueClient) -> None: client.create_project(ProjectCreateRequest(name="bank/au")) assert exc_info.value.response.status_code == 400 + + +def test_get_project_delete_conflicts_parses_each_reason(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/projects/7/delete-check/", json={"conflicts": CONFLICTS}) + conflicts = client.get_project_delete_conflicts(ProjectId(7), ProjectId(8)) + + assert m.last_request.qs == {"destination": ["8"]} + assert conflicts == [ + ProjectDeleteConflict( + reason=ProjectDeleteConflictReason.name_in_use, + detail='The destination project already contains a ruleset named "3407".', + type="rulesets", + id="0b6c2f0e-4a0d-4d8e-9a57-2f0f3c1d9e11", + name="3407", + ), + ProjectDeleteConflict( + reason=ProjectDeleteConflictReason.file_exists, + detail='The destination project already has a file at "projects/8-retail/seeds/names.csv".', + type="seeds", + id="5e3b8a40-7c2d-4f61-b1a9-0d4e6f2c8b77", + name="names.csv", + path="projects/8-retail/seeds/names.csv", + ), + ProjectDeleteConflict( + reason=ProjectDeleteConflictReason.unexpected_file, + detail='The project directory contains "projects/7-banking/notes.txt", which belongs to no resource.', + path="projects/7-banking/notes.txt", + ), + ] + assert conflicts[0].path is None + assert conflicts[2].type is None + assert conflicts[2].id is None + assert conflicts[2].name is None + + +def test_get_project_delete_conflicts_returns_empty_list_when_project_can_be_deleted( + client: DataMasqueClient, +) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/projects/7/delete-check/", json={"conflicts": []}) + conflicts = client.get_project_delete_conflicts(ProjectId(7), ProjectId(8)) + + assert conflicts == [] + + +def test_project_delete_calls_accept_projects_as_well_as_ids(client: DataMasqueClient) -> None: + project = Project.model_validate(CREATE_RESPONSE) + destination = Project.model_validate(CREATE_RESPONSE | {"id": 8, "reference": "8-retail", "name": "Retail"}) + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/projects/7/delete-check/", json={"conflicts": []}) + m.delete("http://test-server/api/projects/7/", status_code=204) + client.get_project_delete_conflicts(project, destination) + client.delete_project(project, destination) + + assert [request.qs for request in m.request_history] == [{"destination": ["8"]}, {"destination": ["8"]}] + + +def test_delete_project(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete("http://test-server/api/projects/7/", status_code=204) + client.delete_project(ProjectId(7), ProjectId(8)) + + assert m.last_request.method == "DELETE" + assert m.last_request.qs == {"destination": ["8"]} + + +def test_delete_project_conflict_raises_with_conflicts(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + "http://test-server/api/projects/7/", + json={"detail": 'Project "Banking" cannot be deleted.', "conflicts": CONFLICTS}, + status_code=409, + ) + with pytest.raises(ProjectDeleteConflictError) as exc_info: + client.delete_project(ProjectId(7), ProjectId(8)) + + assert exc_info.value.response.status_code == 409 + assert str(exc_info.value) == 'Project "Banking" cannot be deleted.' + assert [conflict.reason for conflict in exc_info.value.conflicts] == [ + ProjectDeleteConflictReason.name_in_use, + ProjectDeleteConflictReason.file_exists, + ProjectDeleteConflictReason.unexpected_file, + ] + assert exc_info.value.conflicts[0].name == "3407" + + +@pytest.mark.parametrize("status_code", [400, 404]) +def test_delete_project_raises_a_plain_api_error_for_a_status_other_than_409( + client: DataMasqueClient, status_code: int +) -> None: + with requests_mock.Mocker() as m: + m.delete("http://test-server/api/projects/7/", json={"detail": "Not found."}, status_code=status_code) + with pytest.raises(DataMasqueApiError) as exc_info: + client.delete_project(ProjectId(7), ProjectId(8)) + + assert not isinstance(exc_info.value, ProjectDeleteConflictError) + assert exc_info.value.response.status_code == status_code From 9fd1154b80844c5f203fbb412106cbac59fe48a4 Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Fri, 16 Oct 2026 12:54:47 +1300 Subject: [PATCH 2/3] feat: add list_resources --- HISTORY.rst | 1 + datamasque/client/__init__.py | 12 +++ datamasque/client/base.py | 10 +- datamasque/client/dmclient.py | 2 + datamasque/client/models/resource.py | 71 +++++++++++++ datamasque/client/resources.py | 56 ++++++++++ docs/client.models.rst | 8 ++ docs/client.rst | 8 ++ tests/test_resources.py | 152 +++++++++++++++++++++++++++ 9 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 datamasque/client/models/resource.py create mode 100644 datamasque/client/resources.py create mode 100644 tests/test_resources.py diff --git a/HISTORY.rst b/HISTORY.rst index 34d8bb6..a203e6d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,7 @@ History ------------------ * Added project models and management APIs. +* Added ``list_resources``, which lists resources of every type across projects. 1.6.0 (unreleased) ------------------ diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index 27e6fac..e4ba6c3 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -123,6 +123,13 @@ ProjectDeleteConflictReason, ProjectId, ) +from datamasque.client.models.resource import ( + ConnectionResource, + DiscoveryConfigResource, + Resource, + ResourceTypeName, + RulesetResource, +) from datamasque.client.models.rg_config import RGConfig, RGConfigId from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId @@ -196,6 +203,7 @@ "CommonStatistics", "ConnectionConfig", "ConnectionId", + "ConnectionResource", "ConstraintColumns", "DataMasqueApiError", "DataMasqueClient", @@ -216,6 +224,7 @@ "DiscoveryConfigLibrary", "DiscoveryConfigLibraryId", "DiscoveryConfigNotFoundError", + "DiscoveryConfigResource", "DiscoveryConfigType", "DiscoveryMatch", "CosmosDbConnectionConfig", @@ -287,6 +296,8 @@ "RGConfigId", "RGConfigNotFoundError", "ReferencingForeignKey", + "Resource", + "ResourceTypeName", "Ruleset", "RulesetGenerationRequest", "RulesetGenerationWithRGConfigRequest", @@ -298,6 +309,7 @@ "RulesetPlanOptions", "RulesetPlanPartialUpdateRequest", "RulesetPlanUpdateRequest", + "RulesetResource", "RulesetType", "RunConnectionRef", "RunId", diff --git a/datamasque/client/base.py b/datamasque/client/base.py index 9aa3bea..510f30d 100644 --- a/datamasque/client/base.py +++ b/datamasque/client/base.py @@ -317,8 +317,14 @@ def _iter_paginated( params: Optional[dict] = None, page_size: int = 100, ) -> Iterator[_T]: + """Iterate every `T` across all pages of an admin-server list endpoint.""" + + for results in self._iter_pages(path, params=params, page_size=page_size): + yield from (model.model_validate(item) for item in results) + + def _iter_pages(self, path: str, *, params: Optional[dict] = None, page_size: int = 100) -> Iterator[Any]: """ - Iterate every `T` across all pages of an admin-server list endpoint. + Iterate the `results` of each page of an admin-server list endpoint. Opts into pagination by sending `limit`/`offset` on the first request, then follows the absolute `next` URL returned by the server. @@ -334,7 +340,7 @@ def _iter_paginated( while url: response = self.make_request("GET", url, params=current_params) data = response.json() - yield from (model.model_validate(item) for item in data["results"]) + yield data["results"] url = data.get("next") # The `next` URL is absolute and already contains the pagination cursor; # do not re-send our initial params alongside it. diff --git a/datamasque/client/dmclient.py b/datamasque/client/dmclient.py index 486e3e7..0d98dca 100644 --- a/datamasque/client/dmclient.py +++ b/datamasque/client/dmclient.py @@ -6,6 +6,7 @@ from datamasque.client.files import FileClient from datamasque.client.license import LicenseClient from datamasque.client.projects import ProjectClient +from datamasque.client.resources import ResourceClient from datamasque.client.rg_configs import RGConfigClient from datamasque.client.ruleset_libraries import RulesetLibraryClient from datamasque.client.rulesets import RulesetClient @@ -20,6 +21,7 @@ class DataMasqueClient( LicenseClient, ProjectClient, + ResourceClient, ConnectionClient, RulesetClient, RulesetLibraryClient, diff --git a/datamasque/client/models/resource.py b/datamasque/client/models/resource.py new file mode 100644 index 0000000..a4abec2 --- /dev/null +++ b/datamasque/client/models/resource.py @@ -0,0 +1,71 @@ +"""Resource listing models for the DataMasque API.""" + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from datamasque.client.models.discovery_config import DiscoveryConfigType +from datamasque.client.models.runs import MaskType + + +class ResourceTypeName(str, Enum): + """A type of resource that belongs to a project, named as the resources listing groups it.""" + + connections = "connections" + connection_filesets = "connection_filesets" + oracle_wallets = "oracle_wallets" + snowflake_keys = "snowflake_keys" + table_references = "table_references" + rulesets = "rulesets" + ruleset_libraries = "ruleset_libraries" + discovery_configs = "discovery_configs" + discovery_config_libraries = "discovery_config_libraries" + ruleset_generation_configs = "ruleset_generation_configs" + seeds = "seeds" + + +class Resource(BaseModel): + """ + One resource in the resources listing: the summary the listing gives, not the resource's full model. + + Connections, rulesets and discovery configs are listed as the subclasses that add the field each carries. + """ + + model_config = ConfigDict(extra="allow") + + id: str + name: str + project: Optional[str] = None + """Its project's `-` reference, or `None` for a resource in no project, such as a built-in seed.""" + created: Optional[datetime] = None + """`None` for a type that does not record it, currently connections.""" + modified: Optional[datetime] = None + """`None` for a type that does not record it, currently connections.""" + + +class ConnectionResource(Resource): + """A connection in the resources listing.""" + + mask_type: MaskType + + +class RulesetResource(Resource): + """A ruleset in the resources listing.""" + + mask_type: MaskType + + +class DiscoveryConfigResource(Resource): + """A discovery config in the resources listing.""" + + config_type: DiscoveryConfigType + + +# The model each type's rows are listed as; a type not named here is listed as `Resource`. +RESOURCE_MODEL_BY_TYPE_NAME: dict[str, type[Resource]] = { + ResourceTypeName.connections.value: ConnectionResource, + ResourceTypeName.rulesets.value: RulesetResource, + ResourceTypeName.discovery_configs.value: DiscoveryConfigResource, +} diff --git a/datamasque/client/resources.py b/datamasque/client/resources.py new file mode 100644 index 0000000..d7215aa --- /dev/null +++ b/datamasque/client/resources.py @@ -0,0 +1,56 @@ +from collections import defaultdict +from collections.abc import Sequence +from typing import Optional, Union + +from datamasque.client.base import BaseClient +from datamasque.client.models.project import Project, ProjectId, unwrap_project_id +from datamasque.client.models.resource import RESOURCE_MODEL_BY_TYPE_NAME, Resource, ResourceTypeName + + +class ResourceClient(BaseClient): + """Resource listing API methods. Mixed into `DataMasqueClient`.""" + + def list_resources( + self, + *, + projects: Optional[Sequence[Union[ProjectId, Project]]] = None, + types: Optional[Sequence[ResourceTypeName]] = None, + search: Optional[str] = None, + page_size: int = 500, + ) -> dict[str, list[Resource]]: + """ + Returns the resources the caller can manage, grouped by type name such as `rulesets`, across every page. + + `projects` limits them to those projects, each given as a `Project` or its `ProjectId`; + `types` to those types; and `search` to resources whose name contains every word of it, ignoring case. + The groups are the requested types, or every type the server has when `types` is not given. + An empty `projects` or `types` matches no resources, without a request to the server. + Connections, rulesets and discovery configs are listed as `ConnectionResource`, `RulesetResource` + and `DiscoveryConfigResource`. + + Raises: + DataMasqueApiError: a project does not exist, or any other non-2xx response. + """ + + # `requests` leaves out a parameter whose value is an empty list, which would remove the filter. + if projects is not None and not projects: + return {type_name.value: [] for type_name in types or []} + + if types is not None and not types: + return {} + + params: dict[str, object] = {} + if projects is not None: + params["project"] = [unwrap_project_id(project) for project in projects] + if types is not None: + params["type"] = [type_name.value for type_name in types] + if search is not None: + params["search"] = search + + resources: defaultdict[str, list[Resource]] = defaultdict(list) + for results in self._iter_pages("/api/resources/", params=params, page_size=page_size): + for type_name, rows in results.items(): + model = RESOURCE_MODEL_BY_TYPE_NAME.get(type_name, Resource) + resources[type_name].extend(model.model_validate(row) for row in rows) + + return dict(resources) diff --git a/docs/client.models.rst b/docs/client.models.rst index 0b2f872..bd00a65 100644 --- a/docs/client.models.rst +++ b/docs/client.models.rst @@ -68,6 +68,14 @@ datamasque.client.models.project module :undoc-members: :show-inheritance: +datamasque.client.models.resource module +---------------------------------------- + +.. automodule:: datamasque.client.models.resource + :members: + :undoc-members: + :show-inheritance: + datamasque.client.models.rg_config module ----------------------------------------- diff --git a/docs/client.rst b/docs/client.rst index b0f5a69..276a982 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -92,6 +92,14 @@ datamasque.client.projects module :undoc-members: :show-inheritance: +datamasque.client.resources module +---------------------------------- + +.. automodule:: datamasque.client.resources + :members: + :undoc-members: + :show-inheritance: + datamasque.client.rg_configs module ----------------------------------- diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..7fde2eb --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,152 @@ +"""Tests for listing resources with the DataMasque client.""" + +from datetime import datetime +from typing import Optional + +import pytest +import requests_mock + +from datamasque.client import ( + ConnectionResource, + DataMasqueApiError, + DataMasqueClient, + MaskType, + Project, + ProjectId, + Resource, + ResourceTypeName, + RulesetResource, +) + +RULESET = { + "id": "0b6c2f0e-4a0d-4d8e-9a57-2f0f3c1d9e11", + "name": "mask_customers", + "project": "7-banking", + "created": "2026-10-01T10:00:00Z", + "modified": "2026-10-02T10:00:00Z", + "mask_type": "database", +} +CONNECTION = { + "id": "5e3b8a40-7c2d-4f61-b1a9-0d4e6f2c8b77", + "name": "customers_db", + "project": "7-banking", + "created": None, + "modified": None, + "mask_type": "database", +} +BUILT_IN_SEED = { + "id": "9a1d3c55-2b7e-4c0a-8f16-3e5d7b9c1a24", + "name": "first_names.csv", + "project": None, + "created": "2026-01-01T00:00:00Z", + "modified": "2026-01-01T00:00:00Z", +} + + +def page(results: dict[str, list[dict]], next_url: Optional[str] = None) -> dict: + return { + "count": sum(len(rows) for rows in results.values()), + "next": next_url, + "previous": None, + "results": results, + } + + +def test_list_resources_groups_them_by_type_name(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/resources/", + json=page({"rulesets": [RULESET], "connections": [CONNECTION], "seeds": [BUILT_IN_SEED]}), + ) + resources = client.list_resources() + + assert sorted(resources) == ["connections", "rulesets", "seeds"] + (ruleset,) = resources["rulesets"] + assert isinstance(ruleset, RulesetResource) + assert ruleset.id == RULESET["id"] + assert ruleset.name == "mask_customers" + assert ruleset.project == "7-banking" + assert ruleset.created == datetime.fromisoformat("2026-10-01T10:00:00+00:00") + assert ruleset.mask_type is MaskType.database + (connection,) = resources["connections"] + assert isinstance(connection, ConnectionResource) + assert connection.created is None + (seed,) = resources["seeds"] + assert type(seed) is Resource + assert seed.project is None + + +def test_list_resources_sends_its_filters(client: DataMasqueClient) -> None: + retail = Project.model_validate( + { + "id": 8, + "reference": "8-retail", + "name": "Retail", + "created": "2026-10-01T10:00:00Z", + "updated": "2026-10-01T10:00:00Z", + } + ) + with requests_mock.Mocker() as m: + m.get("http://test-server/api/resources/", json=page({"rulesets": [], "connections": []})) + client.list_resources( + projects=[ProjectId(7), retail], + types=[ResourceTypeName.rulesets, ResourceTypeName.connections], + search="customers", + ) + + assert m.last_request.qs == { + "project": ["7", "8"], + "type": ["rulesets", "connections"], + "search": ["customers"], + "limit": ["500"], + "offset": ["0"], + } + + +@pytest.mark.parametrize( + ("projects", "types", "expected"), + [ + ([], None, {}), + ([], [ResourceTypeName.rulesets], {"rulesets": []}), + ([ProjectId(7)], [], {}), + ], +) +def test_list_resources_with_an_empty_filter_returns_no_resources_without_a_request( + client: DataMasqueClient, + projects: list[ProjectId], + types: Optional[list[ResourceTypeName]], + expected: dict[str, list[Resource]], +) -> None: + with requests_mock.Mocker() as m: + resources = client.list_resources(projects=projects, types=types) + + assert resources == expected + assert m.call_count == 0 + + +def test_list_resources_follows_every_page_and_merges_the_groups(client: DataMasqueClient) -> None: + next_url = "http://test-server/api/resources/?limit=2&offset=2" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/resources/", + [ + {"json": page({"rulesets": [RULESET], "connections": [CONNECTION]}, next_url=next_url)}, + {"json": page({"rulesets": [RULESET | {"id": "c3d1e5f7-0000-4000-8000-000000000001"}]})}, + ], + ) + resources = client.list_resources(page_size=2) + + assert [ruleset.id for ruleset in resources["rulesets"]] == [RULESET["id"], "c3d1e5f7-0000-4000-8000-000000000001"] + assert [connection.id for connection in resources["connections"]] == [CONNECTION["id"]] + assert m.call_count == 2 + assert m.request_history[1].qs == {"limit": ["2"], "offset": ["2"]} + + +def test_list_resources_raises_for_a_caller_who_cannot_list_them(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/resources/", status_code=403, json={"detail": "Forbidden"}) + + with pytest.raises(DataMasqueApiError) as raised: + client.list_resources() + + assert raised.value.response.status_code == 403 From d53aa4affc5ffa26f8c7854eeb93862ed4a2b1f1 Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Fri, 16 Oct 2026 13:48:04 +1300 Subject: [PATCH 3/3] docs: follow the client's docstring patterns for create_project --- datamasque/client/models/project.py | 8 +++----- datamasque/client/projects.py | 5 ++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/datamasque/client/models/project.py b/datamasque/client/models/project.py index 6eedc1b..b264100 100644 --- a/datamasque/client/models/project.py +++ b/datamasque/client/models/project.py @@ -32,11 +32,9 @@ class ProjectCreateRequest(BaseModel): """ Request body for `POST /api/projects/`. - Names are unique across active and archived projects, ignoring case. - The server trims leading and trailing whitespace from `name` and `description`, - and rejects a name that contains "/", ".." or a control character. - It may reject other names too, - and its 400 response gives the reason. + A name must not contain "/", ".." or a control character, + and must differ, ignoring case, from the name of every other project, including deleted ones. + The server trims leading and trailing whitespace from `name` and `description`. """ model_config = ConfigDict(extra="forbid") diff --git a/datamasque/client/projects.py b/datamasque/client/projects.py index 1646b13..7a454a8 100644 --- a/datamasque/client/projects.py +++ b/datamasque/client/projects.py @@ -27,9 +27,8 @@ def create_project(self, request: ProjectCreateRequest) -> Project: """ Creates a new project on the server, and returns it as the server stored it. - Only admin users can create projects, and API tokens cannot. - A name the server rejects (see `ProjectCreateRequest`) raises `DataMasqueApiError` with status 400, - and a caller who is not an admin user raises it with status 403. + Raises: + DataMasqueApiError: the server rejects the name (see `ProjectCreateRequest`), or any other non-2xx response. """ data = request.model_dump(mode="json")