Skip to content

Commit 59d92d9

Browse files
committed
feat: add discovery config and library validation
- Add `validate_discovery_config` and `validate_discovery_config_library`. - Add `validation_error_details` to `DiscoveryConfig` and `usage_count` to `DiscoveryConfigLibrary`. - Reject configs and libraries with no YAML content, with an error that says how to fix it. - Ensure validating leaves nothing behind, even when it fails. - Let pydantic parse the nested errors payload instead of a custom validator - Add DataMasqueArgumentError
1 parent 9508bc4 commit 59d92d9

9 files changed

Lines changed: 492 additions & 28 deletions

HISTORY.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
History
33
=======
44

5+
1.2.2 (unreleased)
6+
------------------
7+
8+
* Added ``validation_error_details`` to ``DiscoveryConfig``.
9+
* Added ``usage_count`` to ``DiscoveryConfigLibrary``.
10+
* Added ``validate_discovery_config`` and ``validate_discovery_config_library``.
11+
* ``update_discovery_config``, ``validate_discovery_config``, and ``validate_discovery_config_library``
12+
* Now raise ``DataMasqueArgumentError`` when the passed entity has no ``yaml`` content,
13+
instead of sending a request the server rejects.
14+
515
1.2.1 (2026-07-30)
616
------------------
717

datamasque/client/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from datamasque.client.exceptions import (
1919
DataMasqueApiError,
20+
DataMasqueException,
2021
DataMasqueNotReadyError,
2122
DataMasqueTransportError,
2223
)
@@ -309,6 +310,12 @@ def _delete_if_exists(self, path: str, *, params: Optional[dict] = None) -> None
309310

310311
self._raise_for_status(response)
311312

313+
def _delete_best_effort(self, delete: Callable[[], None], description: str) -> None:
314+
try:
315+
delete()
316+
except DataMasqueException as e:
317+
logger.warning("Failed to clean up %s; remove it manually. Error: %s", description, e)
318+
312319
def _iter_paginated(
313320
self,
314321
path: str,

datamasque/client/discovery_config_libraries.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import logging
2+
import uuid
23
from typing import Optional
34

45
from datamasque.client.base import BaseClient
5-
from datamasque.client.exceptions import DataMasqueApiError
6+
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueArgumentError
67
from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId
78

89
logger = logging.getLogger(__name__)
@@ -76,15 +77,21 @@ def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
7677
Creates a new discovery config library on the server.
7778
7879
Sets the library's server-assigned fields
79-
(`id`, `is_valid`, `validation_error`, `created`, `modified`) and returns the library.
80+
(`id`, `is_valid`, `validation_error`, `usage_count`, `created`, `modified`) and returns the library.
8081
"""
8182

83+
if not library.yaml:
84+
raise DataMasqueArgumentError(
85+
"Cannot create a discovery config library without YAML content (yaml is empty)"
86+
)
87+
8288
data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
8389
response = self.make_request("POST", "/api/discovery/config-libraries/", data=data)
8490
created = DiscoveryConfigLibrary.model_validate(response.json())
8591
library.id = created.id
8692
library.is_valid = created.is_valid
8793
library.validation_error = created.validation_error
94+
library.usage_count = created.usage_count
8895
library.created = created.created
8996
library.modified = created.modified
9097
logger.info('Creation of discovery config library "%s" successful', library.name)
@@ -99,11 +106,13 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
99106
"""
100107

101108
if library.id is None:
102-
raise ValueError("Cannot update a discovery config library that has not been created yet (id is None)")
109+
raise DataMasqueArgumentError(
110+
"Cannot update a discovery config library that has not been created yet (id is None)"
111+
)
103112

104-
if library.yaml is None:
105-
raise ValueError(
106-
"Cannot update a discovery config library without YAML content (yaml is None); "
113+
if not library.yaml:
114+
raise DataMasqueArgumentError(
115+
"Cannot update a discovery config library without YAML content (yaml is empty or unset); "
107116
"list results omit YAML, so fetch the full library with `get_discovery_config_library` first"
108117
)
109118

@@ -112,6 +121,7 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di
112121
updated = DiscoveryConfigLibrary.model_validate(response.json())
113122
library.is_valid = updated.is_valid
114123
library.validation_error = updated.validation_error
124+
library.usage_count = updated.usage_count
115125
library.modified = updated.modified
116126
logger.debug('Update of discovery config library "%s" successful', library.name)
117127
return library
@@ -130,6 +140,41 @@ def create_or_update_discovery_config_library(self, library: DiscoveryConfigLibr
130140

131141
return self.create_discovery_config_library(library)
132142

143+
def validate_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary:
144+
"""Validates a discovery config library against the server without persisting it."""
145+
146+
if not library.yaml:
147+
raise DataMasqueArgumentError(
148+
"Cannot validate a discovery config library without YAML content (yaml is empty or unset); "
149+
"list results omit YAML, so fetch the full library with `get_discovery_config_library` first"
150+
)
151+
152+
temporary = DiscoveryConfigLibrary(
153+
name=f"dm_python_validate_{uuid.uuid4().hex}",
154+
namespace=library.namespace,
155+
yaml=library.yaml,
156+
)
157+
data = temporary.model_dump(exclude_none=True, by_alias=True, mode="json")
158+
response = self.make_request("POST", "/api/discovery/config-libraries/", data=data)
159+
160+
payload = response.json()
161+
raw_id = payload.get("id") if isinstance(payload, dict) else None
162+
created_id = DiscoveryConfigLibraryId(raw_id) if isinstance(raw_id, str) else None
163+
164+
try:
165+
created = DiscoveryConfigLibrary.model_validate(payload)
166+
library.is_valid = created.is_valid
167+
library.validation_error = created.validation_error
168+
finally:
169+
if created_id is not None:
170+
self._delete_best_effort(
171+
lambda: self.delete_discovery_config_library_by_id_if_exists(created_id),
172+
f'temporary validation library "{temporary.name}"',
173+
)
174+
175+
logger.debug('Validation of discovery config library "%s" complete', library.name)
176+
return library
177+
133178
def delete_discovery_config_library_by_id_if_exists(
134179
self, library_id: DiscoveryConfigLibraryId, *, force: bool = False
135180
) -> None:

datamasque/client/discovery_configs.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import logging
2+
import uuid
23
from typing import Iterator, Optional
34

45
from datamasque.client.base import BaseClient
5-
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException
6+
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueArgumentError, DataMasqueException
67
from datamasque.client.models.discovery_config import DiscoveryConfig, DiscoveryConfigId, DiscoveryConfigType
78
from datamasque.client.models.pagination import Page
89

@@ -75,15 +76,20 @@ def create_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
7576
Creates a new discovery config on the server.
7677
7778
Sets the config's server-assigned fields
78-
(`id`, `is_valid`, `validation_error`, `created`, `modified`) and returns the config.
79+
(`id`, `is_valid`, `validation_error`, `validation_error_details`, `created`, `modified`)
80+
and returns the config.
7981
"""
8082

83+
if not config.yaml:
84+
raise DataMasqueArgumentError("Cannot create a discovery config without YAML content (yaml is empty)")
85+
8186
data = config.model_dump(exclude_none=True, by_alias=True, mode="json")
8287
response = self.make_request("POST", "/api/discovery/configs/", data=data)
8388
created = DiscoveryConfig.model_validate(response.json())
8489
config.id = created.id
8590
config.is_valid = created.is_valid
8691
config.validation_error = created.validation_error
92+
config.validation_error_details = created.validation_error_details
8793
config.created = created.created
8894
config.modified = created.modified
8995
logger.info('Creation of discovery config "%s" successful', config.name)
@@ -94,17 +100,24 @@ def update_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
94100
Performs a full update of the discovery config.
95101
96102
The config must have its `id` set
97-
(i.e., it must have been previously created or retrieved from the server).
103+
and its `yaml` content present.
98104
"""
99105

100106
if config.id is None:
101-
raise ValueError("Cannot update a discovery config that has not been created yet (id is None)")
107+
raise DataMasqueArgumentError("Cannot update a discovery config that has not been created yet (id is None)")
108+
109+
if not config.yaml:
110+
raise DataMasqueArgumentError(
111+
"Cannot update a discovery config without YAML content (yaml is empty or unset); "
112+
"list results omit YAML, so fetch the full config with `get_discovery_config` first"
113+
)
102114

103115
data = config.model_dump(exclude_none=True, by_alias=True, mode="json")
104116
response = self.make_request("PUT", f"/api/discovery/configs/{config.id}/", data=data)
105117
updated = DiscoveryConfig.model_validate(response.json())
106118
config.is_valid = updated.is_valid
107119
config.validation_error = updated.validation_error
120+
config.validation_error_details = updated.validation_error_details
108121
config.modified = updated.modified
109122
logger.debug('Update of discovery config "%s" successful', config.name)
110123
return config
@@ -123,6 +136,42 @@ def create_or_update_discovery_config(self, config: DiscoveryConfig) -> Discover
123136

124137
return self.create_discovery_config(config)
125138

139+
def validate_discovery_config(self, config: DiscoveryConfig) -> DiscoveryConfig:
140+
"""Validates a discovery config against the server."""
141+
142+
if not config.yaml:
143+
raise DataMasqueArgumentError(
144+
"Cannot validate a discovery config without YAML content (yaml is empty or unset); "
145+
"list results omit YAML, so fetch the full config with `get_discovery_config` first"
146+
)
147+
148+
temporary = DiscoveryConfig(
149+
name=f"dm_python_validate_{uuid.uuid4().hex}",
150+
yaml=config.yaml,
151+
config_type=config.config_type,
152+
)
153+
data = temporary.model_dump(exclude_none=True, by_alias=True, mode="json")
154+
response = self.make_request("POST", "/api/discovery/configs/", data=data)
155+
156+
payload = response.json()
157+
raw_id = payload.get("id") if isinstance(payload, dict) else None
158+
created_id = DiscoveryConfigId(raw_id) if isinstance(raw_id, str) else None
159+
160+
try:
161+
created = DiscoveryConfig.model_validate(payload)
162+
config.is_valid = created.is_valid
163+
config.validation_error = created.validation_error
164+
config.validation_error_details = created.validation_error_details
165+
finally:
166+
if created_id is not None:
167+
self._delete_best_effort(
168+
lambda: self.delete_discovery_config_by_id_if_exists(created_id),
169+
f'temporary validation config "{temporary.name}"',
170+
)
171+
172+
logger.debug('Validation of discovery config "%s" complete', config.name)
173+
return config
174+
126175
def delete_discovery_config_by_id_if_exists(self, config_id: DiscoveryConfigId) -> None:
127176
"""
128177
Deletes the discovery config with the given ID.

datamasque/client/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ class DataMasqueUserError(DataMasqueException):
99
"""Raised when error occurs during user creation or configuration."""
1010

1111

12+
class DataMasqueArgumentError(DataMasqueException):
13+
"""
14+
Raised when a client method is given an object it cannot act on.
15+
16+
Covers arguments the client rejects without contacting the server, such as
17+
updating a record that has no `id` yet, or sending a config whose `yaml` is
18+
empty.
19+
"""
20+
21+
1222
class DataMasqueApiError(DataMasqueException):
1323
"""
1424
Raised when the DataMasque server responds to a request with a non-2xx status code.

datamasque/client/models/discovery_config.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from datetime import datetime
33
from typing import Any, NewType, Optional
44

5-
from pydantic import BaseModel, ConfigDict, Field
5+
from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field
66

7-
from datamasque.client.models.status import ValidationStatus
7+
from datamasque.client.models.status import ValidationErrorDetails, ValidationStatus
88

99
DiscoveryConfigId = NewType("DiscoveryConfigId", str)
1010

@@ -49,5 +49,14 @@ class DiscoveryConfig(BaseModel):
4949
"""Validation status; may be `in_progress` briefly after creating a large config."""
5050
validation_error: Optional[str] = Field(default=None, exclude=True)
5151
"""Human-readable validation error, or `None` when valid."""
52+
# Deliberately not `validation_errors`:
53+
# this is a different shape to `Ruleset.validation_errors`,
54+
# and would sit one character from `validation_error` above.
55+
validation_error_details: list[ValidationErrorDetails] = Field(
56+
default_factory=list,
57+
exclude=True,
58+
validation_alias=AliasChoices(AliasPath("errors", "config_yaml"), "validation_error_details"),
59+
)
60+
"""Structured, positional validation errors."""
5261
created: Optional[datetime] = Field(default=None, exclude=True)
5362
modified: Optional[datetime] = Field(default=None, exclude=True)

datamasque/client/models/discovery_config_library.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,7 @@ class DiscoveryConfigLibrary(BaseModel):
2727
"""Validation status; libraries are validated synchronously on create/update."""
2828
validation_error: Optional[str] = Field(default=None, exclude=True)
2929
"""Human-readable validation error, or `None` when valid."""
30+
usage_count: Optional[int] = Field(default=None, exclude=True)
31+
"""Number of active discovery configs that import this library."""
3032
created: Optional[datetime] = Field(default=None, exclude=True)
3133
modified: Optional[datetime] = Field(default=None, exclude=True)

0 commit comments

Comments
 (0)