diff --git a/HISTORY.rst b/HISTORY.rst index 5dd09b9..cf9720f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,11 +2,12 @@ History ======= -1.1.7 (unreleased) +1.1.7 (2026-07-14) ------------------ * Added ``retry_writes`` to ``MongoConnectionConfig`` (default ``True``), serialized only when disabled. Set it to ``False`` for AWS DocumentDB, which rejects retryable writes. +* Updated the validation error API on ``Ruleset`` and ``RulesetLibrary``. 1.1.6 (2026-07-13) ------------------ diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index 8a925e4..e2275e3 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -119,6 +119,7 @@ from datamasque.client.models.status import ( AsyncRulesetGenerationTaskStatus, MaskingRunStatus, + ValidationErrorDetails, ValidationErrorType, ValidationStatus, ) @@ -234,6 +235,7 @@ "UserId", "UserRole", "UserSelection", + "ValidationErrorDetails", "ValidationErrorType", "ValidationStatus", ] diff --git a/datamasque/client/models/ruleset.py b/datamasque/client/models/ruleset.py index 362fc8b..91dc2f0 100644 --- a/datamasque/client/models/ruleset.py +++ b/datamasque/client/models/ruleset.py @@ -4,7 +4,7 @@ from pydantic import Field from datamasque.client.models.git import GitTrackedEntity -from datamasque.client.models.status import ValidationErrorType, ValidationStatus +from datamasque.client.models.status import ValidationErrorDetails, ValidationStatus RulesetId = NewType("RulesetId", str) @@ -44,7 +44,5 @@ class Ruleset(GitTrackedEntity): # Server-populated read-only fields, excluded from request bodies. id: Optional[RulesetId] = Field(default=None, exclude=True) is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) - validation_error: Optional[str] = Field(default=None, exclude=True) - """Human-readable validation error, or `None` when valid.""" - validation_error_type: Optional[ValidationErrorType] = Field(default=None, exclude=True) - """Category of the validation failure, or `None` when valid.""" + validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True) + """Validation errors surfaced by the server; empty when valid.""" diff --git a/datamasque/client/models/ruleset_library.py b/datamasque/client/models/ruleset_library.py index d1e1d3b..22d43c8 100644 --- a/datamasque/client/models/ruleset_library.py +++ b/datamasque/client/models/ruleset_library.py @@ -4,7 +4,7 @@ from pydantic import Field from datamasque.client.models.git import GitTrackedEntity -from datamasque.client.models.status import ValidationStatus +from datamasque.client.models.status import ValidationErrorDetails, ValidationStatus RulesetLibraryId = NewType("RulesetLibraryId", str) @@ -19,7 +19,7 @@ class RulesetLibrary(GitTrackedEntity): # Server-populated read-only fields, excluded from request bodies. id: Optional[RulesetLibraryId] = Field(default=None, exclude=True) is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) - validation_error: Optional[str] = Field(default=None, exclude=True) - """Human-readable validation error, or `None` when valid.""" + validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True) + """Validation errors surfaced by the server; empty when valid.""" created: Optional[datetime] = Field(default=None, exclude=True) modified: Optional[datetime] = Field(default=None, exclude=True) diff --git a/datamasque/client/models/status.py b/datamasque/client/models/status.py index dc7d0ed..0fd8935 100644 --- a/datamasque/client/models/status.py +++ b/datamasque/client/models/status.py @@ -1,4 +1,7 @@ import enum +from typing import Optional + +from pydantic import BaseModel class ValidationStatus(enum.Enum): @@ -11,7 +14,7 @@ class ValidationStatus(enum.Enum): class ValidationErrorType(enum.Enum): - """Categorises why a ruleset failed validation (see `Ruleset.validation_error_type`).""" + """Categorises why validation failed (see `ValidationErrorDetails.validation_error_type`).""" ruleset = "ruleset" library_missing = "library_missing" @@ -19,6 +22,15 @@ class ValidationErrorType(enum.Enum): expansion = "expansion" # The ruleset is not valid once its library references are expanded. +class ValidationErrorDetails(BaseModel): + """A single validation error, with its position in the YAML when available.""" + + message: str + validation_error_type: Optional[ValidationErrorType] = None + line_number: Optional[int] = None + column_number: Optional[int] = None + + class MaskingRunStatus(enum.Enum): """List of valid masking run statuses.""" diff --git a/datamasque/client/ruleset_libraries.py b/datamasque/client/ruleset_libraries.py index e734bc2..2e792c7 100644 --- a/datamasque/client/ruleset_libraries.py +++ b/datamasque/client/ruleset_libraries.py @@ -64,7 +64,7 @@ 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_error`, `git`, `created`, `modified`) and returns the library. + (`id`, `is_valid`, `validation_errors`, `git`, `created`, `modified`) and returns the library. """ data = library.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -72,7 +72,7 @@ def create_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary: created_library = RulesetLibrary.model_validate(response.json()) library.id = created_library.id library.is_valid = created_library.is_valid - library.validation_error = created_library.validation_error + library.validation_errors = created_library.validation_errors library.git = created_library.git library.created = created_library.created library.modified = created_library.modified @@ -93,7 +93,7 @@ def update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary: response = self.make_request("PUT", f"/api/ruleset-libraries/{library.id}/", data=data) updated_library = RulesetLibrary.model_validate(response.json()) library.is_valid = updated_library.is_valid - library.validation_error = updated_library.validation_error + library.validation_errors = updated_library.validation_errors 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 5ea0ce9..28651c1 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_error`, `validation_error_type`, + Populates the given ruleset's `id`, `is_valid`, `validation_errors`, and `git` fields from the server response, and returns the same ruleset instance for convenience. """ @@ -29,8 +29,7 @@ def create_or_update_ruleset(self, ruleset: Ruleset) -> Ruleset: created = Ruleset.model_validate(response.json()) ruleset.id = created.id ruleset.is_valid = created.is_valid - ruleset.validation_error = created.validation_error - ruleset.validation_error_type = created.validation_error_type + ruleset.validation_errors = created.validation_errors ruleset.git = created.git if response.status_code == 201: diff --git a/tests/test_ruleset_library.py b/tests/test_ruleset_library.py index 6698531..a8f0a8e 100644 --- a/tests/test_ruleset_library.py +++ b/tests/test_ruleset_library.py @@ -14,6 +14,7 @@ RulesetLibraryId, ValidationStatus, ) +from datamasque.client.models.status import ValidationErrorDetails LIBRARY_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" LIBRARY_ID_2 = "bbbbbbbb-1111-2222-3333-444444444444" @@ -618,17 +619,17 @@ def test_list_rulesets_using_library_pagination(client: DataMasqueClient) -> Non assert rulesets[2].name == "r3" -def test_create_ruleset_library_populates_validation_error( +def test_create_ruleset_library_populates_validation_errors( client: DataMasqueClient, ruleset_library: RulesetLibrary ) -> None: - """The server's `validation_error` string is surfaced on the returned library.""" + """The server's `validation_errors` list is surfaced on the returned library.""" create_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", "config_yaml": "version: '1.0'\nfunctions: []", "is_valid": "invalid", - "validation_error": "Invalid function definition", + "validation_errors": [{"message": "Invalid function definition"}], "created": "2025-06-01T10:00:00Z", "modified": "2025-06-01T10:00:00Z", } @@ -641,13 +642,14 @@ def test_create_ruleset_library_populates_validation_error( ) result = client.create_ruleset_library(ruleset_library) - assert result.validation_error == "Invalid function definition" + assert len(result.validation_errors) == 1 + assert result.validation_errors[0].message == "Invalid function definition" -def test_update_ruleset_library_populates_validation_error( +def test_update_ruleset_library_populates_validation_errors( client: DataMasqueClient, ruleset_library: RulesetLibrary ) -> None: - """A full update also surfaces the server's `validation_error` string.""" + """A full update also surfaces the server's `validation_errors` list.""" ruleset_library.id = RulesetLibraryId(LIBRARY_ID_1) update_response = { "id": LIBRARY_ID_1, @@ -655,7 +657,7 @@ def test_update_ruleset_library_populates_validation_error( "namespace": "test_ns", "config_yaml": "version: '1.0'\nfunctions: []", "is_valid": "invalid", - "validation_error": "Invalid function definition", + "validation_errors": [{"message": "Invalid function definition"}], "created": "2025-06-01T10:00:00Z", "modified": "2025-06-02T10:00:00Z", } @@ -668,7 +670,8 @@ def test_update_ruleset_library_populates_validation_error( ) result = client.update_ruleset_library(ruleset_library) - assert result.validation_error == "Invalid function definition" + assert len(result.validation_errors) == 1 + assert result.validation_errors[0].message == "Invalid function definition" def test_create_ruleset_library_does_not_send_read_only_fields( @@ -677,14 +680,14 @@ def test_create_ruleset_library_does_not_send_read_only_fields( """Read-only server fields must never appear in the create request body.""" ruleset_library.id = RulesetLibraryId(LIBRARY_ID_1) ruleset_library.is_valid = ValidationStatus.invalid - ruleset_library.validation_error = "stale error" + ruleset_library.validation_errors = [ValidationErrorDetails(message="stale error")] create_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", "config_yaml": "version: '1.0'\nfunctions: []", "is_valid": "invalid", - "validation_error": "Invalid function definition", + "validation_errors": [{"message": "Invalid function definition"}], "created": "2025-06-01T10:00:00Z", "modified": "2025-06-01T10:00:00Z", } @@ -698,7 +701,7 @@ def test_create_ruleset_library_does_not_send_read_only_fields( client.create_ruleset_library(ruleset_library) body = m.last_request.json() - for read_only_field in ("id", "is_valid", "validation_error", "created", "modified"): + for read_only_field in ("id", "is_valid", "validation_errors", "created", "modified"): assert read_only_field not in body # Input fields are still present. assert body["name"] == "test_library" diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py index 1200a68..8be6de7 100644 --- a/tests/test_rulesets.py +++ b/tests/test_rulesets.py @@ -134,8 +134,8 @@ def test_delete_ruleset_that_does_not_exist(client, existing_rulesets_json): assert m.request_history[0].method == "GET" -def test_create_or_update_ruleset_populates_validation_error(client, ruleset): - """The server's `validation_error` string and `validation_error_type` are surfaced on the returned ruleset.""" +def test_create_or_update_ruleset_populates_validation_errors(client, ruleset): + """The server's `validation_errors` list is surfaced on the returned ruleset.""" with requests_mock.Mocker() as m: m.post( "http://test-server/api/rulesets/?upsert=true", @@ -143,19 +143,29 @@ def test_create_or_update_ruleset_populates_validation_error(client, ruleset): "id": "2", "name": "test_ruleset", "is_valid": "invalid", - "validation_error": "Ruleset error: Missing required field: table", - "validation_error_type": "ruleset", + "validation_errors": [ + { + "message": "Missing required field: table", + "validation_error_type": "ruleset", + "line_number": 5, + "column_number": 3, + } + ], }, status_code=201, ) result = client.create_or_update_ruleset(ruleset) - assert result.validation_error == "Ruleset error: Missing required field: table" - assert result.validation_error_type is ValidationErrorType.ruleset + assert len(result.validation_errors) == 1 + error = result.validation_errors[0] + assert error.message == "Missing required field: table" + assert error.validation_error_type is ValidationErrorType.ruleset + assert error.line_number == 5 + assert error.column_number == 3 -def test_create_or_update_ruleset_validation_error_none_when_absent(client, ruleset): - """`validation_error` and `validation_error_type` are `None` when the server omits them (a valid ruleset).""" +def test_create_or_update_ruleset_validation_errors_empty_when_valid(client, ruleset): + """`validation_errors` is empty when the server omits it (a valid ruleset).""" with requests_mock.Mocker() as m: m.post( "http://test-server/api/rulesets/?upsert=true", @@ -164,8 +174,7 @@ def test_create_or_update_ruleset_validation_error_none_when_absent(client, rule ) result = client.create_or_update_ruleset(ruleset) - assert result.validation_error is None - assert result.validation_error_type is None + assert result.validation_errors == [] def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset): @@ -177,8 +186,7 @@ def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset "id": "2", "name": "test_ruleset", "is_valid": "invalid", - "validation_error": "Ruleset error: bad", - "validation_error_type": "ruleset", + "validation_errors": [{"message": "bad", "validation_error_type": "ruleset"}], }, status_code=201, ) @@ -188,7 +196,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_error", "validation_error_type"): + for read_only_field in ("id", "is_valid", "validation_errors"): assert read_only_field not in body # Input fields are still present. assert body["config_yaml"] == "version: '1.0'\ntasks: []"