Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ History
* ``list_connections``, ``list_table_references`` and ``list_files_of_type`` take an optional ``project`` filter.
* ``DataMasqueFile`` gains a read-only ``project`` field, which is ``None`` for built-in seeds.
* ``get_generated_rulesets`` sets each ruleset's ``project`` to the connection's project.
* ``create_or_update_*`` works with a server older than projects, and ``RunInfo.project`` and ``UnfinishedRun.project``

@DamoFury DamoFury Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds only when neither the resource's project nor default_project is set.
RulesetSerializer rejects unknown fields ("Got unknown fields") on every release from 2.17.0 to 3.26.19, and RuleLibrarySerializer does on 3.26.05 to 3.26.19.
So with either set, create_or_update_ruleset and create_or_update_ruleset_library send project and get a 400.
Consider saying that here or editing behaviour?

are ``None`` from such a server.
* Added Git APIs for rulesets, ruleset libraries, discovery configs and discovery config libraries.
* Added bundle export and import APIs.

Expand Down
9 changes: 7 additions & 2 deletions datamasque/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,21 +318,26 @@ def _delete_if_exists(self, path: str, *, params: Optional[dict] = None) -> None

self._raise_for_status(response)

def _resolve_project(self, project: Optional[Union[ProjectId, str]]) -> Union[ProjectId, str]:
def _resolve_project(self, project: Optional[Union[ProjectId, str]]) -> Optional[Union[ProjectId, str]]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still fails on 3.26.20 to 3.26.22, the current release.
Those versions have /api/projects/, but their ProjectSerializer returns ["id", "name", "description", "created", "updated"] with no reference.
Project.reference is required, so this line raises a pydantic ValidationError, and every create_or_update_* with no project and no default_project fails.

This function only needs count and the first id, so read those from the JSON:

page_payload = response.json()
active_project_count = page_payload["count"]
if active_project_count != 1:
    raise DataMasqueException(
        f"No `project` is set and the server has {active_project_count} active projects. Set `project` to choose one."
    )

return ProjectId(page_payload["results"][0]["id"])

Maybe test with a projects page that has no reference would cover it?

"""
Returns `project`, else the client's `default_project`, else the ID of the only active project.

The last is the project the server puts a create into when it is sent no `project`.
With no active project, or more than one, the server rejects such a create,
so this raises `DataMasqueException` instead.
Returns `None` for a server older than projects, which has one set of names and no project filter.

@DamoFury DamoFury Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"which has one set of names" is not true for ruleset and discovery config libraries, which are unique per namespace before 3.26.20 (ruleset libraries from 3.26.05, discovery config libraries from 3.26.14).
Suggest: "Returns None when the server has no /api/projects/, so a lookup sends no project filter."

"""

if project is not None:
return project
if self.default_project is not None:
return self.default_project

response = self.make_request("GET", "/api/projects/", params={"limit": 1})
response = self.make_request("GET", "/api/projects/", params={"limit": 1}, require_status_check=False)
if response.status_code == 404:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add a test that a status other than 404 (for example 403) still raises DataMasqueApiError.
This branch replaces the status check in make_request, and no test checks that only a 404 is treated as a server without projects.

return None

self._raise_for_status(response)
page = Page[Project].model_validate(response.json())
if page.count != 1:
raise DataMasqueException(
Expand Down
2 changes: 1 addition & 1 deletion datamasque/client/discovery_config_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def get_discovery_config_library(self, library_id: DiscoveryConfigLibraryId) ->
return DiscoveryConfigLibrary.model_validate(response.json())

def _get_discovery_config_library_id_by_name(
self, name: str, project: Union[ProjectId, str]
self, name: str, project: Optional[Union[ProjectId, str]]

@DamoFury DamoFury Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When _resolve_project returns None, this lookup matches the name in every namespace.
On 3.26.14 to 3.26.19, discovery config libraries are unique on (namespace, name), and a create from this client goes into the default namespace "".

So if the server has emails only in namespace bank/au, create_or_update_discovery_config_library(DiscoveryConfigLibrary(name="emails", ...)) overwrites that library's YAML.
It should create emails in the default namespace instead.

drop_namespace removes the field from the model, so the filter has to run on the raw items when project is None (line 44):

items = response.json()
if project is None:
    # A server without projects keys libraries on `(namespace, name)`,
    # and a create from this client goes into the default namespace.
    items = [item for item in items if item.get("namespace", "") == ""]

entries = [DiscoveryConfigLibrary.model_validate(item) for item in items]

) -> Optional[DiscoveryConfigLibraryId]:
"""Returns the ID of the library with the given name in the given project, or `None` if there is none."""

Expand Down
2 changes: 1 addition & 1 deletion datamasque/client/discovery_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get_discovery_config(self, config_id: DiscoveryConfigId) -> DiscoveryConfig:
return DiscoveryConfig.model_validate(response.json())

def _get_discovery_config_id_by_name(
self, name: str, config_type: DiscoveryConfigType, project: Union[ProjectId, str]
self, name: str, config_type: DiscoveryConfigType, project: Optional[Union[ProjectId, str]]
) -> Optional[DiscoveryConfigId]:
"""Return the id of the config matching name, type and project via a single list request, or `None`."""

Expand Down
7 changes: 4 additions & 3 deletions datamasque/client/models/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,13 @@ class RunInfo(BaseModel):
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
options: Optional[dict[str, Any]] = None
project: str
project: Optional[str] = None
"""
Its project's `<id>-<slug>` reference, e.g. `1-default`.

The project of the run's ruleset, else its discovery config, else its source connection.
The run moves when that resource moves to another project.
`None` from a server older than projects.
"""

@model_validator(mode="before")
Expand All @@ -166,8 +167,8 @@ class UnfinishedRun(BaseModel):
ruleset_name: str
status: MaskingRunStatus
destination_connection: Optional[RunConnectionRef] = None
project: str
"""Its project's `<id>-<slug>` reference, e.g. `1-default`."""
project: Optional[str] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: only RunInfo has a test for a payload without project.
Add one for UnfinishedRun, for example through get_unfinished_runs.

"""Its project's `<id>-<slug>` reference, e.g. `1-default`. `None` from a server older than projects."""

@model_validator(mode="before")
@classmethod
Expand Down
2 changes: 1 addition & 1 deletion datamasque/client/rg_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get_rg_config(self, config_id: RGConfigId) -> RGConfig:
response = self.make_request("GET", f"/api/ruleset-generation-configs/{config_id}/")
return RGConfig.model_validate(response.json())

def _get_rg_config_id_by_name(self, name: str, project: Union[ProjectId, str]) -> Optional[RGConfigId]:
def _get_rg_config_id_by_name(self, name: str, project: Optional[Union[ProjectId, str]]) -> Optional[RGConfigId]:
"""Return the id of the config matching the name and project via a single list request, or `None`."""

response = self.make_request(
Expand Down
4 changes: 3 additions & 1 deletion datamasque/client/ruleset_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ def get_ruleset_library(self, library_id: RulesetLibraryId) -> RulesetLibrary:
response = self.make_request("GET", f"/api/ruleset-libraries/{library_id}/")
return RulesetLibrary.model_validate(response.json())

def _get_ruleset_library_id_by_name(self, name: str, project: Union[ProjectId, str]) -> Optional[RulesetLibraryId]:
def _get_ruleset_library_id_by_name(
self, name: str, project: Optional[Union[ProjectId, str]]

@DamoFury DamoFury Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same problem as in discovery_config_libraries.py.
Here it can also go wrong when a default-namespace library exists:
on 3.26.05 to 3.26.19 ruleset libraries are unique on (namespace, name) and this list is ordered by name only, so limit: 1 can return a library from any namespace with that name.

Keep limit. Without it the server returns a plain list (PAGE_SIZE is None), and Page validation fails.
Instead, order by namespace so that "" comes first, and drop a namespaced result:

params = {"name_exact": name, "project": project, "limit": 1}
if project is None:
    # A server without projects keys libraries on `(namespace, name)`.
    # Ordering by namespace puts the default namespace first.
    params["ordering"] = "namespace"

response = self.make_request("GET", "/api/ruleset-libraries/", params=params)
page_payload = response.json()
if project is None:
    page_payload["results"] = [item for item in page_payload["results"] if item.get("namespace", "") == ""]

page = Page[RulesetLibrary].model_validate(page_payload)

) -> Optional[RulesetLibraryId]:
"""Return the ID of the library with the given name in the given project, or `None` if there is none."""

response = self.make_request(
Expand Down
4 changes: 3 additions & 1 deletion datamasque/client/table_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ def get_table_reference_by_name(self, name: str, project: ProjectIdentifier) ->

return matches[0]

def _get_table_reference_id_by_name(self, name: str, project: Union[ProjectId, str]) -> Optional[TableReferenceId]:
def _get_table_reference_id_by_name(
self, name: str, project: Optional[Union[ProjectId, str]]
) -> Optional[TableReferenceId]:
"""Return the ID of the table reference with the given name in the given project, or `None` if there is none."""

response = self.make_request("GET", "/api/table-references/", params={"project": project})
Expand Down
27 changes: 22 additions & 5 deletions tests/test_default_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from tests.helpers import active_projects_page, database_connection_config

BASE_URL = "http://test-server"
EMPTY_PAGE = {"count": 0, "next": None, "previous": None, "results": []}

@DamoFury DamoFury Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/test_projects.py:307 already defines EMPTY_PAGE.
Consider moving it to tests/helpers.py and import it in both files.



def make_client(default_project: Optional[str] = None) -> DataMasqueClient:
Expand All @@ -41,36 +42,41 @@ def create_connection(client: DataMasqueClient, project: Optional[str], m: reque


def create_discovery_config(client: DataMasqueClient, project: Optional[str], m: requests_mock.Mocker) -> Any:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These five helpers now call create_or_update_* instead of create_*.
The three existing tests that use CREATES were written for the direct creates.
They still go through create_*, because create_or_update_* calls it.
But if _default_project_field moved into create_or_update_*, create_discovery_config would stop sending default_project and these tests would still pass.

Maybe reverting the five helpers to create_* and giving the new test its own parametrised list of create_or_update_* helpers.
Those helpers can leave project out of the mocked responses, since an old server doesn't return it.

m.get(f"{BASE_URL}/api/discovery/configs/", json=EMPTY_PAGE)
m.post(
f"{BASE_URL}/api/discovery/configs/",
json={"id": "dc-1", "name": "dc", "config_type": "database", "project": "2-acme"},
)
config = DiscoveryConfig(name="dc", yaml="labels: []\n", config_type="database", project=project)
return client.create_discovery_config(config)
return client.create_or_update_discovery_config(config)


def create_rg_config(client: DataMasqueClient, project: Optional[str], m: requests_mock.Mocker) -> Any:
m.get(f"{BASE_URL}/api/ruleset-generation-configs/", json=EMPTY_PAGE)
m.post(f"{BASE_URL}/api/ruleset-generation-configs/", json={"id": "rg-1", "name": "rg", "project": "2-acme"})
return client.create_rg_config(RGConfig(name="rg", yaml="x: 1\n", project=project))
return client.create_or_update_rg_config(RGConfig(name="rg", yaml="x: 1\n", project=project))


def create_table_reference(client: DataMasqueClient, project: Optional[str], m: requests_mock.Mocker) -> Any:
m.get(f"{BASE_URL}/api/table-references/", json=[])
m.post(
f"{BASE_URL}/api/table-references/",
json={"id": "tr-1", "name": "tr", "connection": "c-1", "source": "people.csv", "project": "2-acme"},
)
table_reference = TableReference(name="tr", connection=ConnectionId("c-1"), source="people.csv", project=project)
return client.create_table_reference(table_reference)
return client.create_or_update_table_reference(table_reference)


def create_ruleset_library(client: DataMasqueClient, project: Optional[str], m: requests_mock.Mocker) -> Any:
m.get(f"{BASE_URL}/api/ruleset-libraries/", json=EMPTY_PAGE)
m.post(f"{BASE_URL}/api/ruleset-libraries/", json={"id": "rl-1", "name": "rl", "project": "2-acme"})
return client.create_ruleset_library(RulesetLibrary(name="rl", yaml="version: '1.0'\n", project=project))
return client.create_or_update_ruleset_library(RulesetLibrary(name="rl", yaml="version: '1.0'\n", project=project))


def create_discovery_config_library(client: DataMasqueClient, project: Optional[str], m: requests_mock.Mocker) -> Any:
m.get(f"{BASE_URL}/api/discovery/config-libraries/", json=[])
m.post(f"{BASE_URL}/api/discovery/config-libraries/", json={"id": "dl-1", "name": "dl", "project": "2-acme"})
return client.create_discovery_config_library(
return client.create_or_update_discovery_config_library(
DiscoveryConfigLibrary(name="dl", yaml="labels: []\n", project=project)
)

Expand Down Expand Up @@ -114,6 +120,17 @@ def test_a_create_with_no_default_project_sends_no_project(create: Create) -> No
assert "project" not in m.last_request.json()


@pytest.mark.parametrize("create", CREATES)
def test_a_create_on_a_server_without_projects_sends_no_project(create: Create) -> None:
"""A server older than projects returns 404 for `/api/projects/`, and a create must still work there."""
with requests_mock.Mocker() as m:
m.get(f"{BASE_URL}/api/projects/", status_code=404)
create(make_client(), None, m)

assert "project" not in m.last_request.json()
assert all("project" not in request.qs for request in m.request_history)


def test_a_connection_update_sends_no_project() -> None:
"""An update leaves the connection in its own project, whatever the default."""
client = make_client(default_project="1-default")
Expand Down
16 changes: 16 additions & 0 deletions tests/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,22 @@ def test_get_run_info(client):
assert result.name == "r1"


def test_run_info_from_a_server_without_projects_has_no_project(client):
with requests_mock.Mocker() as m:
m.get(
"http://test-server/api/runs/1/",
json={
"id": 1,
"status": "finished",
"mask_type": "database",
"source_connection_name": "conn1",
"ruleset_name": "rs1",
},
)

assert client.get_run_info(1).project is None


def test_masking_run_request_model_dump_minimal():
"""A minimal request dumps with an empty `options` object (the server rejects missing `options`)."""
req = MaskingRunRequest(connection="conn-1", ruleset="rs-1")
Expand Down
Loading