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 c7fe9af..fa51751 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -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 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/models/ruleset_library.py b/datamasque/client/models/ruleset_library.py index 22d43c8..915d1c6 100644 --- a/datamasque/client/models/ruleset_library.py +++ b/datamasque/client/models/ruleset_library.py @@ -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) diff --git a/datamasque/client/ruleset_libraries.py b/datamasque/client/ruleset_libraries.py index 1b999c2..ce5fddb 100644 --- a/datamasque/client/ruleset_libraries.py +++ b/datamasque/client/ruleset_libraries.py @@ -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") @@ -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 @@ -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) 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/tests/test_ruleset_library.py b/tests/test_ruleset_library.py index a8f0a8e..b3fec81 100644 --- a/tests/test_ruleset_library.py +++ b/tests/test_ruleset_library.py @@ -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 @@ -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() diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py index 8be6de7..f304439 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 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: @@ -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: []"