From c5e8c28df526f8f71b3fd73b1dfdaa42267b7973 Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Fri, 18 Sep 2026 09:41:59 +1200 Subject: [PATCH] feat: DM-4759 model the unlicensed features a ruleset uses --- CONTRIBUTING.rst | 6 ++ HISTORY.rst | 8 +++ datamasque/client/models/ruleset.py | 6 ++ datamasque/client/rulesets.py | 3 +- pyproject.toml | 2 +- tests/test_rulesets.py | 85 ++++++++++++++++++++++++++++- uv.lock | 2 +- 7 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f24ed60..0c8ad92 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -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 =============== diff --git a/HISTORY.rst b/HISTORY.rst index b443c97..b5c59fc 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,14 @@ History ======= +1.3.2 (2026-09-17) +------------------ + +* Added ``unlicensed_feature_warnings`` to ``Ruleset``, naming the features the ruleset uses + that the server's license does not allow. + +Requires server version 3.26.18 + 1.3.1 (2026-09-09) ------------------ diff --git a/datamasque/client/models/ruleset.py b/datamasque/client/models/ruleset.py index 91dc2f0..e7973dd 100644 --- a/datamasque/client/models/ruleset.py +++ b/datamasque/client/models/ruleset.py @@ -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. + """ diff --git a/datamasque/client/rulesets.py b/datamasque/client/rulesets.py index 28651c1..96fc98c 100644 --- a/datamasque/client/rulesets.py +++ b/datamasque/client/rulesets.py @@ -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. """ @@ -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: diff --git a/pyproject.toml b/pyproject.toml index c907a34..efab9a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "datamasque-python" -version = "1.3.1" +version = "1.3.2" description = "Official Python client for the DataMasque data-masking API." authors = [ { name = "DataMasque Ltd" }, diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py index 8be6de7..14aec0b 100644 --- a/tests/test_rulesets.py +++ b/tests/test_rulesets.py @@ -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 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: @@ -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, ) @@ -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: []" diff --git a/uv.lock b/uv.lock index c53fc85..3a1c637 100644 --- a/uv.lock +++ b/uv.lock @@ -419,7 +419,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.3.1" +version = "1.3.2" source = { editable = "." } dependencies = [ { name = "pydantic" },