diff --git a/HISTORY.rst b/HISTORY.rst index 983534b..453853e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -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) ------------------ diff --git a/datamasque/client/discovery_config_libraries.py b/datamasque/client/discovery_config_libraries.py index 25c69a3..bd1e1fb 100644 --- a/datamasque/client/discovery_config_libraries.py +++ b/datamasque/client/discovery_config_libraries.py @@ -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) @@ -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" ) @@ -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 diff --git a/datamasque/client/discovery_configs.py b/datamasque/client/discovery_configs.py index 60f448d..d19f900 100644 --- a/datamasque/client/discovery_configs.py +++ b/datamasque/client/discovery_configs.py @@ -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) @@ -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 diff --git a/datamasque/client/models/discovery_config.py b/datamasque/client/models/discovery_config.py index 09b123f..d1a0aa8 100644 --- a/datamasque/client/models/discovery_config.py +++ b/datamasque/client/models/discovery_config.py @@ -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) @@ -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) diff --git a/datamasque/client/models/discovery_config_library.py b/datamasque/client/models/discovery_config_library.py index 9dbec49..d73b82f 100644 --- a/datamasque/client/models/discovery_config_library.py +++ b/datamasque/client/models/discovery_config_library.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 43aaab7..2945092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }, diff --git a/tests/test_discovery_config_libraries.py b/tests/test_discovery_config_libraries.py index d72cff3..20a0d4d 100644 --- a/tests/test_discovery_config_libraries.py +++ b/tests/test_discovery_config_libraries.py @@ -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: @@ -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( @@ -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", } @@ -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( @@ -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", } @@ -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( @@ -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) @@ -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) diff --git a/tests/test_discovery_configs.py b/tests/test_discovery_configs.py index caa4b31..d34c47d 100644 --- a/tests/test_discovery_configs.py +++ b/tests/test_discovery_configs.py @@ -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 @@ -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 = { @@ -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) diff --git a/uv.lock b/uv.lock index 0f6f752..1a32e7c 100644 --- a/uv.lock +++ b/uv.lock @@ -419,7 +419,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.2.1" +version = "1.2.2" source = { editable = "." } dependencies = [ { name = "pydantic" },