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

1.2.1 (unreleased)
------------------

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

1.2.0 (2026-07-27)
------------------

Expand Down
7 changes: 7 additions & 0 deletions datamasque/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

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

self._raise_for_status(response)

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
40 changes: 39 additions & 1 deletion datamasque/client/discovery_config_libraries.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import uuid
from typing import Optional

from datamasque.client.base import BaseClient
Expand Down Expand Up @@ -76,7 +77,7 @@ 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.
"""

data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
Expand All @@ -85,6 +86,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.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 Down Expand Up @@ -112,6 +114,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 All @@ -130,6 +133,41 @@ 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) -> DiscoveryConfigLibrary:
"""Validates a discovery config library against the server without persisting it."""

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

temporary = DiscoveryConfigLibrary(
name=f"dm_python_validate_{uuid.uuid4().hex}",
namespace=library.namespace,
yaml=library.yaml,
)
data = temporary.model_dump(exclude_none=True, by_alias=True, mode="json")
response = self.make_request("POST", "/api/discovery/config-libraries/", data=data)

payload = response.json()
raw_id = payload.get("id") if isinstance(payload, dict) else None
created_id = DiscoveryConfigLibraryId(raw_id) if isinstance(raw_id, str) else None

try:
created = DiscoveryConfigLibrary.model_validate(payload)
library.is_valid = created.is_valid
library.validation_error = created.validation_error
finally:
if created_id is not None:
self._delete_best_effort(
lambda: self.delete_discovery_config_library_by_id_if_exists(created_id),
f'temporary validation library "{temporary.name}"',
)

logger.debug('Validation of discovery config library "%s" complete', library.name)
return library

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

from datamasque.client.base import BaseClient
Expand Down Expand Up @@ -84,6 +85,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 @@ -94,17 +96,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 config.yaml is None:
raise ValueError(
"Cannot update a discovery config without YAML content (yaml is None); "
"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 All @@ -123,6 +132,42 @@ def create_or_update_discovery_config(self, config: DiscoveryConfig) -> Discover

return self.create_discovery_config(config)

def validate_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
"""Validates a discovery config against the server."""

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

temporary = DiscoveryConfig(
name=f"dm_python_validate_{uuid.uuid4().hex}",
yaml=config.yaml,
config_type=config.config_type,
)
data = temporary.model_dump(exclude_none=True, by_alias=True, mode="json")
response = self.make_request("POST", "/api/discovery/configs/", data=data)

payload = response.json()
raw_id = payload.get("id") if isinstance(payload, dict) else None
created_id = DiscoveryConfigId(raw_id) if isinstance(raw_id, str) else None

try:
created = DiscoveryConfig.model_validate(payload)
config.is_valid = created.is_valid
config.validation_error = created.validation_error
config.validation_error_details = created.validation_error_details
finally:
if created_id is not None:
self._delete_best_effort(
lambda: self.delete_discovery_config_by_id_if_exists(created_id),
f'temporary validation config "{temporary.name}"',
)

logger.debug('Validation of discovery config "%s" complete', config.name)
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
28 changes: 26 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, BaseModel, ConfigDict, Field, JsonValue, field_validator

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

@field_validator("validation_error_details", mode="before")
@classmethod
def flatten_grouped_errors_into_details(cls, value: JsonValue) -> JsonValue:
"""Flatten the server's field-grouped `errors` map, keeping the group key on each entry."""

if not isinstance(value, dict):
return value

flattened: list[JsonValue] = []
for field_name, entries in value.items():
if not isinstance(entries, list):
raise ValueError(
f"Expected a list of validation errors under {field_name!r}, got {type(entries).__name__}"
)
flattened.extend({**entry, "field": field_name} if isinstance(entry, dict) else entry for entry in entries)

return flattened
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: 2 additions & 0 deletions datamasque/client/models/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class ValidationErrorDetails(BaseModel):
"""A single validation error, with its position in the YAML when available."""

message: str
field: Optional[str] = None
"""Serializer field the server grouped this error under, when it grouped them."""
validation_error_type: Optional[ValidationErrorType] = None
line_number: Optional[int] = None
column_number: Optional[int] = None
Expand Down
Loading
Loading