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
3 changes: 2 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
from datamasque.client.models.status import (
AsyncRulesetGenerationTaskStatus,
MaskingRunStatus,
ValidationErrorDetails,
ValidationErrorType,
ValidationStatus,
)
Expand Down Expand Up @@ -234,6 +235,7 @@
"UserId",
"UserRole",
"UserSelection",
"ValidationErrorDetails",
"ValidationErrorType",
"ValidationStatus",
]
8 changes: 3 additions & 5 deletions datamasque/client/models/ruleset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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."""
6 changes: 3 additions & 3 deletions datamasque/client/models/ruleset_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
14 changes: 13 additions & 1 deletion datamasque/client/models/status.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import enum
from typing import Optional

from pydantic import BaseModel


class ValidationStatus(enum.Enum):
Expand All @@ -11,14 +14,23 @@ 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"
library_invalid = "library_invalid"
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."""

Expand Down
6 changes: 3 additions & 3 deletions datamasque/client/ruleset_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ 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")
response = self.make_request("POST", "/api/ruleset-libraries/", data=data)
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
Expand All @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions 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_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.
"""

Expand All @@ -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:
Expand Down
25 changes: 14 additions & 11 deletions tests/test_ruleset_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
}
Expand All @@ -641,21 +642,22 @@ 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,
"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-02T10:00:00Z",
}
Expand All @@ -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(
Expand All @@ -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",
}
Expand All @@ -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"
Expand Down
34 changes: 21 additions & 13 deletions tests/test_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,28 +134,38 @@ 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",
json={
"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",
Expand All @@ -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):
Expand All @@ -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,
)
Expand All @@ -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: []"
Expand Down
Loading