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
11 changes: 11 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
History
=======

1.2.2 (2026-07-31)
------------------

* Added ``validation_error_details`` to ``DiscoveryConfig``.
* Added ``usage_count`` to ``DiscoveryConfigLibrary``.
* ``create_discovery_config``, ``update_discovery_config``, ``create_discovery_config_library``, and
``update_discovery_config_library`` now raise ``ValueError`` when the passed entity has no ``yaml``
content, instead of sending a request the server rejects.

Requires server version 3.26.14

1.2.1 (2026-07-30)
------------------

Expand Down
11 changes: 8 additions & 3 deletions datamasque/client/discovery_config_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,19 @@ def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
Creates a new discovery config library on the server.

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

if not library.yaml:
raise ValueError("Cannot create a discovery config library without YAML content (yaml is empty)")

data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
response = self.make_request("POST", "/api/discovery/config-libraries/", data=data)
created = DiscoveryConfigLibrary.model_validate(response.json())
library.id = created.id
library.is_valid = created.is_valid
library.validation_error = created.validation_error
library.usage_count = created.usage_count
library.created = created.created
library.modified = created.modified
logger.info('Creation of discovery config library "%s" successful', library.name)
Expand All @@ -101,9 +105,9 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
if library.id is None:
raise ValueError("Cannot update a discovery config library that has not been created yet (id is None)")

if library.yaml is None:
if not library.yaml:
raise ValueError(
"Cannot update a discovery config library without YAML content (yaml is None); "
"Cannot update a discovery config library without YAML content (yaml is empty or unset); "
"list results omit YAML, so fetch the full library with `get_discovery_config_library` first"
)

Expand All @@ -112,6 +116,7 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
updated = DiscoveryConfigLibrary.model_validate(response.json())
library.is_valid = updated.is_valid
library.validation_error = updated.validation_error
library.usage_count = updated.usage_count
library.modified = updated.modified
logger.debug('Update of discovery config library "%s" successful', library.name)
return library
Expand Down
16 changes: 14 additions & 2 deletions datamasque/client/discovery_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,20 @@ def create_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
Creates a new discovery config on the server.

Sets the config's server-assigned fields
(`id`, `is_valid`, `validation_error`, `created`, `modified`) and returns the config.
(`id`, `is_valid`, `validation_error`, `validation_error_details`, `created`, `modified`)
and returns the config.
"""

if not config.yaml:
raise ValueError("Cannot create a discovery config without YAML content (yaml is empty)")

data = config.model_dump(exclude_none=True, by_alias=True, mode="json")
response = self.make_request("POST", "/api/discovery/configs/", data=data)
created = DiscoveryConfig.model_validate(response.json())
config.id = created.id
config.is_valid = created.is_valid
config.validation_error = created.validation_error
config.validation_error_details = created.validation_error_details
config.created = created.created
config.modified = created.modified
logger.info('Creation of discovery config "%s" successful', config.name)
Expand All @@ -94,17 +99,24 @@ def update_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
Performs a full update of the discovery config.

The config must have its `id` set
(i.e., it must have been previously created or retrieved from the server).
and its `yaml` content present.
"""

if config.id is None:
raise ValueError("Cannot update a discovery config that has not been created yet (id is None)")

if not config.yaml:
raise ValueError(
"Cannot update a discovery config without YAML content (yaml is empty or unset); "
"list results omit YAML, so fetch the full config with `get_discovery_config` first"
)

data = config.model_dump(exclude_none=True, by_alias=True, mode="json")
response = self.make_request("PUT", f"/api/discovery/configs/{config.id}/", data=data)
updated = DiscoveryConfig.model_validate(response.json())
config.is_valid = updated.is_valid
config.validation_error = updated.validation_error
config.validation_error_details = updated.validation_error_details
config.modified = updated.modified
logger.debug('Update of discovery config "%s" successful', config.name)
return config
Expand Down
13 changes: 11 additions & 2 deletions datamasque/client/models/discovery_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from datetime import datetime
from typing import Any, NewType, Optional

from pydantic import BaseModel, ConfigDict, Field
from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field

from datamasque.client.models.status import ValidationStatus
from datamasque.client.models.status import ValidationErrorDetails, ValidationStatus

DiscoveryConfigId = NewType("DiscoveryConfigId", str)

Expand Down Expand Up @@ -49,5 +49,14 @@ class DiscoveryConfig(BaseModel):
"""Validation status; may be `in_progress` briefly after creating a large config."""
validation_error: Optional[str] = Field(default=None, exclude=True)
"""Human-readable validation error, or `None` when valid."""
# Deliberately not `validation_errors`:
# this is a different shape to `Ruleset.validation_errors`,
# and would sit one character from `validation_error` above.
validation_error_details: list[ValidationErrorDetails] = Field(
default_factory=list,
exclude=True,
validation_alias=AliasChoices(AliasPath("errors", "config_yaml"), "validation_error_details"),
)
"""Structured, positional validation errors."""
created: Optional[datetime] = Field(default=None, exclude=True)
modified: Optional[datetime] = Field(default=None, exclude=True)
2 changes: 2 additions & 0 deletions datamasque/client/models/discovery_config_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ class DiscoveryConfigLibrary(BaseModel):
"""Validation status; libraries are validated synchronously on create/update."""
validation_error: Optional[str] = Field(default=None, exclude=True)
"""Human-readable validation error, or `None` when valid."""
usage_count: Optional[int] = Field(default=None, exclude=True)
"""Number of active discovery configs that import this library."""
created: Optional[datetime] = Field(default=None, exclude=True)
modified: Optional[datetime] = Field(default=None, exclude=True)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "datamasque-python"
version = "1.2.1"
version = "1.2.2"
description = "Official Python client for the DataMasque data-masking API."
authors = [
{ name = "DataMasque Ltd" },
Expand Down
39 changes: 28 additions & 11 deletions tests/test_discovery_config_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ def test_list_discovery_config_libraries(
assert libraries[0].namespace == "org"
assert libraries[0].yaml is None
assert libraries[0].is_valid is ValidationStatus.valid
assert libraries[0].usage_count == 0
assert libraries[1].id == DiscoveryConfigLibraryId(LIBRARY_ID_2)
assert libraries[1].name == "another_library"
assert libraries[1].is_valid is ValidationStatus.invalid
assert libraries[1].validation_error == "bad yaml"
assert libraries[1].usage_count == 2


def test_list_discovery_config_libraries_empty(client: DataMasqueClient) -> None:
Expand Down Expand Up @@ -125,6 +127,7 @@ def test_get_discovery_config_library(client: DataMasqueClient, sample_library_d
assert library.namespace == "org"
assert library.yaml == "labels: []\nmetadata_rules: []\nidd_rules: []\n"
assert library.is_valid is ValidationStatus.valid
assert library.usage_count == 0


def test_get_discovery_config_library_by_name_found(
Expand Down Expand Up @@ -248,6 +251,7 @@ def test_create_discovery_config_library(client: DataMasqueClient, config_librar
"config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n",
"is_valid": "valid",
"validation_error": None,
"usage_count": 0,
"created": "2025-06-01T10:00:00Z",
"modified": "2025-06-01T10:00:00Z",
}
Expand All @@ -263,16 +267,16 @@ def test_create_discovery_config_library(client: DataMasqueClient, config_librar
assert result is config_library
assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1)
assert result.is_valid is ValidationStatus.valid
assert result.usage_count == 0
assert result.created == datetime.fromisoformat("2025-06-01T10:00:00+00:00")
assert result.modified == datetime.fromisoformat("2025-06-01T10:00:00+00:00")

request_body = m.last_request.json()
assert request_body["name"] == "test_library"
assert request_body["namespace"] == "test_ns"
assert "config_type" not in request_body
assert request_body["config_yaml"] == "labels: []\nmetadata_rules: []\nidd_rules: []\n"
read_only_fields = {"id", "is_valid", "validation_error", "created", "modified"}
assert not read_only_fields & request_body.keys()
assert request_body == {
"name": "test_library",
"namespace": "test_ns",
"config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n",
}


def test_create_discovery_config_library_reports_validation_error(
Expand Down Expand Up @@ -312,6 +316,7 @@ def test_update_discovery_config_library(client: DataMasqueClient, config_librar
"config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n",
"is_valid": "valid",
"validation_error": None,
"usage_count": 3,
"created": "2025-06-01T10:00:00Z",
"modified": "2025-06-02T10:00:00Z",
}
Expand All @@ -327,13 +332,15 @@ def test_update_discovery_config_library(client: DataMasqueClient, config_librar
assert result is config_library
assert result.is_valid is ValidationStatus.valid
assert result.validation_error is None
assert result.usage_count == 3
assert result.modified == datetime.fromisoformat("2025-06-02T10:00:00+00:00")

request_body = m.last_request.json()
assert request_body["name"] == "test_library"
assert request_body["config_yaml"] == "labels: []\nmetadata_rules: []\nidd_rules: []\n"
read_only_fields = {"id", "is_valid", "validation_error", "created", "modified"}
assert not read_only_fields & request_body.keys()
assert request_body == {
"name": "test_library",
"namespace": "test_ns",
"config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n",
}


def test_update_discovery_config_library_no_id_raises(
Expand All @@ -349,7 +356,7 @@ def test_update_discovery_config_library_without_yaml_raises(
config_library.id = DiscoveryConfigLibraryId(LIBRARY_ID_1)
config_library.yaml = None

with pytest.raises(ValueError, match="yaml is None"):
with pytest.raises(ValueError, match="without YAML content"):
client.update_discovery_config_library(config_library)


Expand Down Expand Up @@ -493,3 +500,13 @@ def test_delete_discovery_config_library_by_name_not_found(

assert m.call_count == 1
assert m.request_history[0].method == "GET"


def test_create_discovery_config_library_with_empty_yaml_raises(client: DataMasqueClient) -> None:
library = DiscoveryConfigLibrary(name="test_library", yaml="")

with requests_mock.Mocker() as m:
with pytest.raises(ValueError, match="without YAML content"):
client.create_discovery_config_library(library)

assert not any(request.method == "POST" for request in m.request_history)
94 changes: 94 additions & 0 deletions tests/test_discovery_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest
import requests_mock
from pydantic import JsonValue, ValidationError

from datamasque.client import DataMasqueClient
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException
Expand Down Expand Up @@ -310,6 +311,16 @@ def test_update_discovery_config_no_id_raises(client: DataMasqueClient, discover
client.update_discovery_config(discovery_config)


def test_update_discovery_config_without_yaml_raises(
client: DataMasqueClient, discovery_config: DiscoveryConfig
) -> None:
discovery_config.id = DiscoveryConfigId(CONFIG_ID_1)
discovery_config.yaml = None

with pytest.raises(ValueError, match="without YAML content"):
client.update_discovery_config(discovery_config)


def test_create_or_update_discovery_config_create(client: DataMasqueClient, discovery_config: DiscoveryConfig) -> None:
empty_list = {"count": 0, "next": None, "previous": None, "results": []}
create_response = {
Expand Down Expand Up @@ -518,3 +529,86 @@ def test_unwrap_discovery_config_id_raises_without_id() -> None:
config = DiscoveryConfig(name="x", config_type="database")
with pytest.raises(ValueError, match="id is None"):
unwrap_discovery_config_id(config)


def _build_config_response(is_valid: str, **extra: JsonValue) -> dict[str, JsonValue]:
return {
"id": CONFIG_ID_1,
"name": "test_config",
"config_type": "database",
"is_valid": is_valid,
"validation_error": None,
**extra,
}


def test_discovery_config_promotes_errors_payload() -> None:
config = DiscoveryConfig.model_validate(
_build_config_response(
"invalid",
validation_error="Unknown mask 'foo'",
errors={"config_yaml": [{"message": "Unknown mask 'foo'", "line_number": 3, "column_number": 5}]},
)
)

assert config.is_valid is ValidationStatus.invalid
assert len(config.validation_error_details) == 1
detail = config.validation_error_details[0]
assert detail.message == "Unknown mask 'foo'"
assert detail.line_number == 3
assert detail.column_number == 5
assert "errors" not in (config.model_extra or {})


def test_discovery_config_accepts_flat_details_list() -> None:
config = DiscoveryConfig.model_validate(
_build_config_response(
"invalid",
validation_error_details=[{"message": "Unknown mask 'foo'", "line_number": 3}],
)
)

assert [detail.message for detail in config.validation_error_details] == ["Unknown mask 'foo'"]


def test_discovery_config_accepts_empty_errors_map() -> None:
config = DiscoveryConfig.model_validate(_build_config_response("valid", errors={}))

assert config.validation_error_details == []


def test_discovery_config_rejects_non_list_error_group() -> None:
with pytest.raises(ValidationError, match="config_yaml"):
DiscoveryConfig.model_validate(_build_config_response("invalid", errors={"config_yaml": "Unknown mask 'foo'"}))


def test_discovery_config_rejects_null_error_group() -> None:
with pytest.raises(ValidationError, match="config_yaml"):
DiscoveryConfig.model_validate(_build_config_response("invalid", errors={"config_yaml": None}))


def test_discovery_config_rejects_malformed_error_entry() -> None:
with pytest.raises(ValidationError):
DiscoveryConfig.model_validate(
_build_config_response("invalid", errors={"config_yaml": ["Unknown mask 'foo'"]})
)


def test_create_discovery_config_with_empty_yaml_raises(client: DataMasqueClient) -> None:
config = DiscoveryConfig(name="test_config", yaml="", config_type="database")

with requests_mock.Mocker() as m:
with pytest.raises(ValueError, match="without YAML content"):
client.create_discovery_config(config)

assert not any(request.method == "POST" for request in m.request_history)


def test_update_discovery_config_with_empty_yaml_raises(client: DataMasqueClient) -> None:
config = DiscoveryConfig(id=CONFIG_ID_1, name="test_config", yaml="", config_type="database")

with requests_mock.Mocker() as m:
with pytest.raises(ValueError, match="without YAML content"):
client.update_discovery_config(config)

assert not any(request.method == "PUT" for request in m.request_history)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading