Skip to content
Draft
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
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ History
* ``safe_data_preview`` on schema-discovery result columns and file-discovery locators, typed by ``kind``.

* Added ``row_count`` to ``TableConstraints`` in schema-discovery ``table_metadata``.
* Added ``validate_discovery_config`` and ``validate_discovery_config_library``, plus a
``validation_error_details`` field on the discovery config and library models.

1.1.7 (2026-07-14)
------------------
Expand Down
29 changes: 29 additions & 0 deletions datamasque/client/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import platform
import sys
import time
import warnings
from contextlib import contextmanager
from dataclasses import dataclass
Expand All @@ -17,6 +18,7 @@

from datamasque.client.exceptions import (
DataMasqueApiError,
DataMasqueException,
DataMasqueNotReadyError,
DataMasqueTransportError,
)
Expand Down Expand Up @@ -309,6 +311,33 @@ def _delete_if_exists(self, path: str, *, params: Optional[dict] = None) -> None

self._raise_for_status(response)

def _poll_until_done(
self,
fetch: Callable[[], _T],
is_done: Callable[[_T], bool],
initial: _T,
*,
timeout: float,
poll_interval: float,
) -> _T:
if is_done(initial):
return initial

deadline = time.monotonic() + timeout
latest = initial
while time.monotonic() < deadline:
time.sleep(poll_interval)
latest = fetch()
if is_done(latest):
return latest
return latest

def _delete_best_effort(self, delete: Callable[[], None], description: str) -> None:
try:
delete()
except DataMasqueException:
logger.warning('Failed to clean up %s; remove it manually.', description)

def _iter_paginated(
self,
path: str,
Expand Down
43 changes: 43 additions & 0 deletions datamasque/client/discovery_config_libraries.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
import uuid
from typing import Optional

from datamasque.client.base import BaseClient
from datamasque.client.exceptions import DataMasqueApiError
from datamasque.client.models.discovery_config import DiscoveryConfigType
from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId
from datamasque.client.models.status import ValidationStatus

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -95,6 +97,7 @@ def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
library.id = created.id
library.is_valid = created.is_valid
library.validation_error = created.validation_error
library.validation_error_details = created.validation_error_details
library.created = created.created
library.modified = created.modified
logger.info('Creation of discovery config library "%s" successful', library.name)
Expand Down Expand Up @@ -123,6 +126,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.validation_error_details = updated.validation_error_details
library.modified = updated.modified
logger.debug('Update of discovery config library "%s" successful', library.name)
return library
Expand All @@ -141,6 +145,45 @@ def create_or_update_discovery_config_library(self, library: DiscoveryConfigLibr

return self.create_discovery_config_library(library)

def validate_discovery_config_library(
self,
library: DiscoveryConfigLibrary,
*,
timeout: float = 60.0,
poll_interval: float = 1.0,
) -> DiscoveryConfigLibrary:
"""Validate a discovery config library's YAML server-side and return it with the verdict populated."""

temp = DiscoveryConfigLibrary(
name=f"__dm_validate_{uuid.uuid4().hex}",
namespace=library.namespace,
yaml=library.yaml,
config_type=library.config_type,
)
created = self.create_discovery_config_library(temp)
settled = created
temp_id = created.id
if temp_id is not None:
library_id = temp_id
try:
settled = self._poll_until_done(
lambda: self.get_discovery_config_library(library_id),
lambda lib: lib.is_valid is not ValidationStatus.in_progress,
created,
timeout=timeout,
poll_interval=poll_interval,
)
finally:
self._delete_best_effort(
lambda: self.delete_discovery_config_library_by_id_if_exists(library_id),
f"discovery config library `{library_id}`",
)

library.is_valid = settled.is_valid
library.validation_error = settled.validation_error
library.validation_error_details = settled.validation_error_details
return library

def delete_discovery_config_library_by_id_if_exists(
self, library_id: DiscoveryConfigLibraryId, *, force: bool = False
) -> None:
Expand Down
42 changes: 42 additions & 0 deletions datamasque/client/discovery_configs.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
import uuid
from typing import Iterator, Optional

from datamasque.client.base import BaseClient
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException
from datamasque.client.models.discovery_config import DiscoveryConfig, DiscoveryConfigId, DiscoveryConfigType
from datamasque.client.models.pagination import Page
from datamasque.client.models.status import ValidationStatus

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -84,6 +86,7 @@ def create_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
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 @@ -105,6 +108,7 @@ def update_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
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 All @@ -123,6 +127,44 @@ def create_or_update_discovery_config(self, config: DiscoveryConfig) -> Discover

return self.create_discovery_config(config)

def validate_discovery_config(
self,
config: DiscoveryConfig,
*,
timeout: float = 60.0,
poll_interval: float = 1.0,
) -> DiscoveryConfig:
"""Validate a discovery config's YAML server-side and return it with the verdict populated."""

temp = DiscoveryConfig(
name=f"__dm_validate_{uuid.uuid4().hex}",
yaml=config.yaml,
config_type=config.config_type,
)
created = self.create_discovery_config(temp)
settled = created
temp_id = created.id
if temp_id is not None:
config_id = temp_id
try:
settled = self._poll_until_done(
lambda: self.get_discovery_config(config_id),
lambda c: c.is_valid is not ValidationStatus.in_progress,
created,
timeout=timeout,
poll_interval=poll_interval,
)
finally:
self._delete_best_effort(
lambda: self.delete_discovery_config_by_id_if_exists(config_id),
f"discovery config `{config_id}`",
)

config.is_valid = settled.is_valid
config.validation_error = settled.validation_error
config.validation_error_details = settled.validation_error_details
return config

def delete_discovery_config_by_id_if_exists(self, config_id: DiscoveryConfigId) -> None:
"""
Deletes the discovery config with the given ID.
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 BaseModel, ConfigDict, Field, model_validator

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

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."""
validation_error_details: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
"""Structured, positional validation errors."""
created: Optional[datetime] = Field(default=None, exclude=True)
modified: Optional[datetime] = Field(default=None, exclude=True)

@model_validator(mode="before")
@classmethod
def _promote_error_locations(cls, data: object) -> object:
"""Flatten the server's `errors` payload into `validation_error_details`."""

return promote_error_locations(data)
13 changes: 11 additions & 2 deletions datamasque/client/models/discovery_config_library.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from datetime import datetime
from typing import NewType, Optional

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from datamasque.client.models.discovery_config import DiscoveryConfigType
from datamasque.client.models.status import ValidationStatus
from datamasque.client.models.status import ValidationErrorDetails, ValidationStatus, promote_error_locations

DiscoveryConfigLibraryId = NewType("DiscoveryConfigLibraryId", str)

Expand All @@ -29,5 +29,14 @@ 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."""
validation_error_details: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
"""Structured, positional validation errors."""
created: Optional[datetime] = Field(default=None, exclude=True)
modified: Optional[datetime] = Field(default=None, exclude=True)

@model_validator(mode="before")
@classmethod
def _promote_error_locations(cls, data: object) -> object:
"""Flatten a server `errors` payload into `validation_error_details`, if one is present."""

return promote_error_locations(data)
19 changes: 19 additions & 0 deletions datamasque/client/models/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ class ValidationErrorDetails(BaseModel):
column_number: Optional[int] = None


def promote_error_locations(data: object) -> object:
"""Flatten a server `errors` payload into a `validation_error_details` list."""

if not isinstance(data, dict) or "errors" not in data:
return data
data = dict(data)
raw_errors = data.pop("errors")
entries: list[object] = []
if isinstance(raw_errors, dict):
for value in raw_errors.values():
if isinstance(value, list):
entries.extend(value)
elif isinstance(raw_errors, list):
entries = raw_errors
if entries:
data["validation_error_details"] = entries
return data


class MaskingRunStatus(enum.Enum):
"""List of valid masking run statuses."""

Expand Down
53 changes: 53 additions & 0 deletions tests/test_discovery_config_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,3 +623,56 @@ def test_delete_discovery_config_library_by_name_not_found(

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


def _build_library_response(is_valid: str, **extra: object) -> dict[str, object]:
return {
"id": LIBRARY_ID_1,
"name": "__dm_validate_stub",
"namespace": "",
"config_type": "database",
"is_valid": is_valid,
"validation_error": None,
"usage_count": 0,
**extra,
}


def test_validate_discovery_config_library_valid(client: DataMasqueClient) -> None:
library = DiscoveryConfigLibrary(name="mylib", namespace="org", yaml="blocks: {}\n", config_type="database")
with requests_mock.Mocker() as m:
m.post(
"http://test-server/api/discovery/config-libraries/",
json=_build_library_response("valid", namespace="org"),
status_code=201,
)
m.delete(f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", status_code=204)
result = client.validate_discovery_config_library(library)

assert result.is_valid is ValidationStatus.valid
assert result.validation_error is None
assert result.name == "mylib"
assert result.namespace == "org"
assert result.id is None

post = m.request_history[0]
assert post.method == "POST"
assert post.json()["name"].startswith("__dm_validate_")
assert m.request_history[-1].method == "DELETE"


def test_validate_discovery_config_library_invalid(client: DataMasqueClient) -> None:
library = DiscoveryConfigLibrary(name="mylib", yaml="bad: [\n", config_type="file")
with requests_mock.Mocker() as m:
m.post(
"http://test-server/api/discovery/config-libraries/",
json=_build_library_response("invalid", config_type="file", validation_error="could not parse YAML"),
status_code=201,
)
m.delete(f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", status_code=204)
result = client.validate_discovery_config_library(library)

assert result.is_valid is ValidationStatus.invalid
assert result.validation_error == "could not parse YAML"
assert not any(r.method == "GET" for r in m.request_history)
assert m.request_history[-1].method == "DELETE"
Loading
Loading