Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ Pull requests
5. Open a PR against ``main`` and describe what the change does and why.
6. The maintainers will review and either merge, request changes, or close with an explanation.

Changelog
=========

Create a new version heading in ``HISTORY.rst``,
and add one concise bullet point for each high-level change.

Commit messages
===============

Expand Down
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ History
otherwise it is ``None``.
It is excluded when a config is serialized back to the API,
as it must never be sent on create or update.
* Added ``unlicensed_feature_warnings`` to ``Ruleset`` and ``RulesetLibrary``, naming the features each uses
that the server's license does not allow.

Requires server version 3.26.18

Expand Down
6 changes: 6 additions & 0 deletions datamasque/client/models/ruleset.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ class Ruleset(GitTrackedEntity):
is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True)
validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
"""Validation errors surfaced by the server; empty when valid."""
unlicensed_feature_warnings: Optional[list[str]] = Field(default=None, exclude=True)
"""
One sentence per feature the ruleset uses that the server's license does not allow.
Empty when the ruleset needs nothing its license withholds,
and `None` until the ruleset has passed validation, which is not the same as having no warnings.
"""
5 changes: 5 additions & 0 deletions datamasque/client/models/ruleset_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@ class RulesetLibrary(GitTrackedEntity):
is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True)
validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
"""Validation errors surfaced by the server; empty when valid."""
unlicensed_feature_warnings: Optional[list[str]] = Field(default=None, exclude=True)
"""
One sentence per feature the library uses that the server's license does not allow.
Empty when the license allows every feature, and `None` until the library has passed validation.
"""
created: Optional[datetime] = Field(default=None, exclude=True)
modified: Optional[datetime] = Field(default=None, exclude=True)
5 changes: 4 additions & 1 deletion datamasque/client/ruleset_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ def create_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
Creates a new ruleset library on the server.

Sets the library's server-assigned fields
(`id`, `is_valid`, `validation_errors`, `git`, `created`, `modified`) and returns the library.
(`id`, `is_valid`, `validation_errors`, `unlicensed_feature_warnings`, `git`, `created`, `modified`)
and returns the library.
"""

data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
Expand All @@ -73,6 +74,7 @@ def create_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
library.id = created_library.id
library.is_valid = created_library.is_valid
library.validation_errors = created_library.validation_errors
library.unlicensed_feature_warnings = created_library.unlicensed_feature_warnings
library.git = created_library.git
library.created = created_library.created
library.modified = created_library.modified
Expand All @@ -94,6 +96,7 @@ def update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
updated_library = RulesetLibrary.model_validate(response.json())
library.is_valid = updated_library.is_valid
library.validation_errors = updated_library.validation_errors
library.unlicensed_feature_warnings = updated_library.unlicensed_feature_warnings
library.git = updated_library.git
library.modified = updated_library.modified
logger.debug('Update of ruleset library "%s" successful', library.name)
Expand Down
3 changes: 2 additions & 1 deletion datamasque/client/rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def create_or_update_ruleset(self, ruleset: Ruleset) -> Ruleset:
"""
Creates or updates a ruleset.

Populates the given ruleset's `id`, `is_valid`, `validation_errors`,
Populates the given ruleset's `id`, `is_valid`, `validation_errors`, `unlicensed_feature_warnings`,
and `git` fields from the server response, and returns the same ruleset instance for convenience.
"""

Expand All @@ -30,6 +30,7 @@ def create_or_update_ruleset(self, ruleset: Ruleset) -> Ruleset:
ruleset.id = created.id
ruleset.is_valid = created.is_valid
ruleset.validation_errors = created.validation_errors
ruleset.unlicensed_feature_warnings = created.unlicensed_feature_warnings
ruleset.git = created.git

if response.status_code == 201:
Expand Down
36 changes: 35 additions & 1 deletion tests/test_ruleset_library.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for ruleset library support in the DataMasque client."""

from datetime import datetime
from typing import Any
from typing import Any, Optional

import pytest
import requests_mock
Expand Down Expand Up @@ -739,3 +739,37 @@ def test_create_ruleset_library_collapses_git_snapshot(
assert result.git.commit_sha == "abc123"
assert result.git.repo_url == "https://git.example.com/repo.git"
assert result.git.synced_at == datetime.fromisoformat("2025-06-01T10:00:00+00:00")


UNSTRUCTURED_WARNING = "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."


@pytest.mark.parametrize("returned", [[UNSTRUCTURED_WARNING], [], None])
def test_create_and_update_ruleset_library_populate_unlicensed_feature_warnings(
client: DataMasqueClient, ruleset_library: RulesetLibrary, returned: Optional[list[str]]
) -> None:
"""`None` (not yet validated) stays distinct from `[]` (no warnings) on both the create and the update path."""
response = {
"id": LIBRARY_ID_1,
"name": "test_library",
"namespace": "test_ns",
"config_yaml": "version: '1.0'\nfunctions: []",
"is_valid": "valid",
"unlicensed_feature_warnings": returned,
"created": "2025-06-01T10:00:00Z",
"modified": "2025-06-01T10:00:00Z",
}

with requests_mock.Mocker() as m:
m.post("http://test-server/api/ruleset-libraries/", json=response, status_code=201)
m.put(f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", json=response)

created = client.create_ruleset_library(ruleset_library)
assert created.unlicensed_feature_warnings == returned
assert "unlicensed_feature_warnings" not in m.last_request.json()

created.unlicensed_feature_warnings = ["stale"]
updated = client.update_ruleset_library(created)

assert updated.unlicensed_feature_warnings == returned
assert "unlicensed_feature_warnings" not in m.last_request.json()
85 changes: 84 additions & 1 deletion tests/test_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,86 @@ def test_create_or_update_ruleset_validation_errors_empty_when_valid(client, rul
assert result.validation_errors == []


UNSTRUCTURED_WARNING = "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
SUBSETTING_WARNING = "Foundation license does not allow Subsetting. Upgrade to Structured or Enterprise."


@pytest.mark.parametrize(
("returned", "expected"),
[
([UNSTRUCTURED_WARNING, SUBSETTING_WARNING], [UNSTRUCTURED_WARNING, SUBSETTING_WARNING]),
([], []),
(None, None),
],
ids=["warned", "fully_licensed", "not_yet_known"],
)
def test_create_or_update_ruleset_populates_unlicensed_feature_warnings(client, ruleset, returned, expected):
"""Every warning the server sends is carried, and an empty list stays distinct from `None`."""
with requests_mock.Mocker() as m:
m.post(
"http://test-server/api/rulesets/?upsert=true",
json={"id": "2", "name": "test_ruleset", "is_valid": "valid", "unlicensed_feature_warnings": returned},
status_code=201,
)
result = client.create_or_update_ruleset(ruleset)

assert result.unlicensed_feature_warnings == expected


def test_create_or_update_ruleset_unlicensed_feature_warnings_default_to_none(client, ruleset):
"""A server that omits the field leaves the warnings unknown rather than empty."""
with requests_mock.Mocker() as m:
m.post(
"http://test-server/api/rulesets/?upsert=true",
json={"id": "2", "name": "test_ruleset", "is_valid": "in_progress"},
status_code=201,
)
result = client.create_or_update_ruleset(ruleset)

assert result.unlicensed_feature_warnings is None


def test_list_rulesets_unlicensed_feature_warnings(client):
"""Each listed ruleset carries its own `unlicensed_feature_warnings`, including `None` for an unvalidated one."""
with requests_mock.Mocker() as m:
m.get(
"http://test-server/api/v2/rulesets/",
json=[
{
"id": "1",
"name": "unstructured_ruleset",
"mask_type": "database",
"is_valid": "valid",
"unlicensed_feature_warnings": [
"Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
],
},
{
"id": "2",
"name": "licensed_ruleset",
"mask_type": "database",
"is_valid": "valid",
"unlicensed_feature_warnings": [],
},
{
"id": "3",
"name": "unvalidated_ruleset",
"mask_type": "database",
"is_valid": "in_progress",
"unlicensed_feature_warnings": None,
},
],
status_code=200,
)
rulesets = client.list_rulesets()

assert rulesets[0].unlicensed_feature_warnings == [
"Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
]
assert rulesets[1].unlicensed_feature_warnings == []
assert rulesets[2].unlicensed_feature_warnings is None


def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset):
"""Read-only server fields must never be echoed back into a re-submit's request body."""
with requests_mock.Mocker() as m:
Expand All @@ -187,6 +267,9 @@ def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset
"name": "test_ruleset",
"is_valid": "invalid",
"validation_errors": [{"message": "bad", "validation_error_type": "ruleset"}],
"unlicensed_feature_warnings": [
"Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
],
},
status_code=201,
)
Expand All @@ -196,7 +279,7 @@ def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset
client.create_or_update_ruleset(ruleset)

body = m.last_request.json()
for read_only_field in ("id", "is_valid", "validation_errors"):
for read_only_field in ("id", "is_valid", "validation_errors", "unlicensed_feature_warnings"):
assert read_only_field not in body
# Input fields are still present.
assert body["config_yaml"] == "version: '1.0'\ntasks: []"
Expand Down