From 074879ee873d4eaf48db44f5aefc87a5ff888575 Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Mon, 6 Jul 2026 14:11:37 +1200 Subject: [PATCH 1/4] feat(discovery): DM-3801 add discovery config library CRUD client APIs --- HISTORY.rst | 5 + .../client/discovery_config_libraries.py | 142 +++++ datamasque/client/dmclient.py | 2 + .../client/models/discovery_config_library.py | 26 + tests/test_discovery_config_libraries.py | 485 ++++++++++++++++++ 5 files changed, 660 insertions(+) create mode 100644 datamasque/client/discovery_config_libraries.py create mode 100644 datamasque/client/models/discovery_config_library.py create mode 100644 tests/test_discovery_config_libraries.py diff --git a/HISTORY.rst b/HISTORY.rst index b00f455..a9caa3b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,11 @@ History ======= +1.1.6 (unreleased) +------------------ + +* Added discovery-config-library management APIs (``list_discovery_config_libraries``, ``create_discovery_config_library``, and friends). + 1.1.5 (2026-06-29) ------------------ diff --git a/datamasque/client/discovery_config_libraries.py b/datamasque/client/discovery_config_libraries.py new file mode 100644 index 0000000..f3dd47a --- /dev/null +++ b/datamasque/client/discovery_config_libraries.py @@ -0,0 +1,142 @@ +import logging +from typing import Iterator, Optional + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId +from datamasque.client.models.pagination import Page + +logger = logging.getLogger(__name__) + + +class DiscoveryConfigLibraryClient(BaseClient): + """Discovery config library CRUD API methods. Mixed into `DataMasqueClient`.""" + + def iter_discovery_config_libraries(self) -> Iterator[DiscoveryConfigLibrary]: + """Lazily iterate all discovery config libraries via the paginated endpoint.""" + + return self._iter_paginated("/api/discovery/config-libraries/", model=DiscoveryConfigLibrary) + + def list_discovery_config_libraries(self) -> list[DiscoveryConfigLibrary]: + """ + Lists all (non-archived) discovery config libraries. + + Note: the YAML content is not included in the list response for performance. + Use `get_discovery_config_library` to retrieve the full library with its YAML body. + """ + + return list(self.iter_discovery_config_libraries()) + + def get_discovery_config_library(self, library_id: DiscoveryConfigLibraryId) -> DiscoveryConfigLibrary: + """Retrieves a single discovery config library by ID, including its YAML content.""" + + response = self.make_request("GET", f"/api/discovery/config-libraries/{library_id}/") + return DiscoveryConfigLibrary.model_validate(response.json()) + + def get_discovery_config_library_by_name(self, name: str, namespace: str = "") -> Optional[DiscoveryConfigLibrary]: + """ + Looks for a discovery config library matching the given name and namespace (case-sensitive, exact match). + + Returns it (with full YAML content) if found, otherwise `None`. + """ + + response = self.make_request( + "GET", + "/api/discovery/config-libraries/", + params={"name_exact": name, "namespace_exact": namespace, "limit": 1}, + ) + page = Page[DiscoveryConfigLibrary].model_validate(response.json()) + if not page.results: + return None + + library_id = page.results[0].id + if library_id is None: + raise DataMasqueApiError( + "Server returned a discovery config library list entry without an `id`.", + response=response, + ) + + return self.get_discovery_config_library(library_id) + + def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: + """ + Creates a new discovery config library on the server. + + Sets the library's server-assigned fields (`id`, `is_valid`, `created`, `modified`) and returns the library. + """ + + 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.created = created.created + library.modified = created.modified + logger.info('Creation of discovery config library "%s" successful', library.name) + return library + + def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: + """ + Performs a full update of the discovery config library. + + The library must have its `id` set (i.e., it must have been previously created or retrieved from the server). + """ + + if library.id is None: + raise ValueError("Cannot update a discovery config library that has not been created yet (id is None)") + + data = library.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("PUT", f"/api/discovery/config-libraries/{library.id}/", data=data) + updated = DiscoveryConfigLibrary.model_validate(response.json()) + library.is_valid = updated.is_valid + library.modified = updated.modified + logger.debug('Update of discovery config library "%s" successful', library.name) + return library + + def create_or_update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: + """ + Creates the library if it doesn't exist, or updates it if one with the same name and namespace already exists. + + Sets the library's `id` property. + """ + + existing = self.get_discovery_config_library_by_name(library.name, library.namespace) + if existing is not None: + library.id = existing.id + return self.update_discovery_config_library(library) + + return self.create_discovery_config_library(library) + + def delete_discovery_config_library_by_id_if_exists( + self, library_id: DiscoveryConfigLibraryId, *, force: bool = False + ) -> None: + """ + Deletes (archives) the discovery config library with the given ID. + + No-op if the library does not exist. + + If the library is imported by any discovery configs, + the server will return 409 Conflict unless `force=True` is passed. + """ + + params = {"force": "true"} if force else None + self._delete_if_exists(f"/api/discovery/config-libraries/{library_id}/", params=params) + + def delete_discovery_config_library_by_name_if_exists( + self, name: str, namespace: str = "", *, force: bool = False + ) -> None: + """ + Deletes the discovery config library with the given name and namespace. + + No-op if the library does not exist. + """ + + all_libraries = self.list_discovery_config_libraries() + matching = [lib for lib in all_libraries if lib.name == name and lib.namespace == namespace] + for lib in matching: + if lib.id is None: + raise DataMasqueException( + f'Server returned a discovery config library named "{lib.name}" without an `id`.' + ) + + self.delete_discovery_config_library_by_id_if_exists(lib.id, force=force) diff --git a/datamasque/client/dmclient.py b/datamasque/client/dmclient.py index 25efcc6..4b9ba3d 100644 --- a/datamasque/client/dmclient.py +++ b/datamasque/client/dmclient.py @@ -1,6 +1,7 @@ from datamasque.client.base import FileOrContent, UploadFile from datamasque.client.connections import ConnectionClient from datamasque.client.discovery import DiscoveryClient +from datamasque.client.discovery_config_libraries import DiscoveryConfigLibraryClient from datamasque.client.discovery_configs import DiscoveryConfigClient from datamasque.client.files import FileClient from datamasque.client.license import LicenseClient @@ -22,6 +23,7 @@ class DataMasqueClient( RunClient, DiscoveryClient, DiscoveryConfigClient, + DiscoveryConfigLibraryClient, UserClient, SettingsClient, ): diff --git a/datamasque/client/models/discovery_config_library.py b/datamasque/client/models/discovery_config_library.py new file mode 100644 index 0000000..1132a63 --- /dev/null +++ b/datamasque/client/models/discovery_config_library.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from datamasque.client.models.status import ValidationStatus + +DiscoveryConfigLibraryId = NewType("DiscoveryConfigLibraryId", str) + + +class DiscoveryConfigLibrary(BaseModel): + """Represents a named, namespaced, persisted YAML discovery config library.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + namespace: str = "" + yaml: Optional[str] = Field(default=None, alias="config_yaml") + id: Optional[DiscoveryConfigLibraryId] = None + # Server-managed validation surface, populated by the DataMasque server. + # `is_valid` may be `in_progress` immediately after creating a large library, + # transitioning to `valid` or `invalid` once the server finishes validating. + is_valid: Optional[ValidationStatus] = None + validation_error: Optional[str] = None + created: Optional[datetime] = None + modified: Optional[datetime] = None diff --git a/tests/test_discovery_config_libraries.py b/tests/test_discovery_config_libraries.py new file mode 100644 index 0000000..6c23202 --- /dev/null +++ b/tests/test_discovery_config_libraries.py @@ -0,0 +1,485 @@ +"""Tests for discovery config library support in the DataMasque client.""" + +from datetime import datetime +from typing import Any + +import pytest +import requests_mock + +from datamasque.client import DataMasqueClient +from datamasque.client.exceptions import DataMasqueApiError +from datamasque.client.models.discovery_config_library import ( + DiscoveryConfigLibrary, + DiscoveryConfigLibraryId, + ValidationStatus, +) + +LIBRARY_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" +LIBRARY_ID_2 = "bbbbbbbb-1111-2222-3333-444444444444" + + +@pytest.fixture +def sample_library_list_response() -> dict[str, Any]: + """Paginated list response (without config_yaml).""" + return { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "another_library", + "namespace": "", + "is_valid": "invalid", + "created": "2025-02-01T12:00:00Z", + "modified": "2025-02-02T12:00:00Z", + }, + ], + } + + +@pytest.fixture +def sample_library_detail_response() -> dict[str, Any]: + """Detail response (with config_yaml).""" + return { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "validation_error": None, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + + +@pytest.fixture +def config_library() -> DiscoveryConfigLibrary: + return DiscoveryConfigLibrary( + name="test_library", + namespace="test_ns", + yaml="labels: []\nmetadata_rules: []\nidd_rules: []\n", + ) + + +def test_list_discovery_config_libraries( + client: DataMasqueClient, sample_library_list_response: dict[str, Any] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=sample_library_list_response, + status_code=200, + ) + libraries = client.list_discovery_config_libraries() + + assert len(libraries) == 2 + assert libraries[0].id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + assert libraries[0].name == "my_library" + assert libraries[0].namespace == "org" + assert libraries[0].yaml is None + assert libraries[0].is_valid is ValidationStatus.valid + assert libraries[1].id == DiscoveryConfigLibraryId(LIBRARY_ID_2) + assert libraries[1].name == "another_library" + assert libraries[1].is_valid is ValidationStatus.invalid + + +def test_list_discovery_config_libraries_pagination(client: DataMasqueClient) -> None: + page1 = { + "count": 3, + "next": "http://test-server/api/discovery/config-libraries/?limit=2&offset=2", + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "lib1", + "namespace": "", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "lib2", + "namespace": "", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + page2 = { + "count": 3, + "next": None, + "previous": "http://test-server/api/discovery/config-libraries/?limit=2", + "results": [ + { + "id": "cccccccc-1111-2222-3333-444444444444", + "name": "lib3", + "namespace": "", + "is_valid": "unknown", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], + ) + libraries = client.list_discovery_config_libraries() + + assert len(libraries) == 3 + assert libraries[0].name == "lib1" + assert libraries[1].name == "lib2" + assert libraries[2].name == "lib3" + + +def test_list_discovery_config_libraries_empty(client: DataMasqueClient) -> None: + empty_response = {"count": 0, "next": None, "previous": None, "results": []} + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=empty_response, + status_code=200, + ) + libraries = client.list_discovery_config_libraries() + + assert libraries == [] + + +def test_get_discovery_config_library(client: DataMasqueClient, sample_library_detail_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=sample_library_detail_response, + status_code=200, + ) + library = client.get_discovery_config_library(DiscoveryConfigLibraryId(LIBRARY_ID_1)) + + assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + assert library.name == "my_library" + assert library.namespace == "org" + assert library.yaml == "labels: []\nmetadata_rules: []\nidd_rules: []\n" + assert library.is_valid is ValidationStatus.valid + + +def test_get_discovery_config_library_by_name_found( + client: DataMasqueClient, sample_library_detail_response: dict[str, Any] +) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=list_response, + status_code=200, + ) + m.get( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=sample_library_detail_response, + status_code=200, + ) + library = client.get_discovery_config_library_by_name("my_library", "org") + + assert library is not None + assert library.name == "my_library" + assert library.yaml == "labels: []\nmetadata_rules: []\nidd_rules: []\n" + assert "name_exact=my_library" in m.request_history[0].url + assert "namespace_exact=org" in m.request_history[0].url + + +def test_get_discovery_config_library_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: + """If the server returns a list entry without `id`, the by-name lookup surfaces a typed API error.""" + list_response_without_id = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=list_response_without_id, + status_code=200, + ) + with pytest.raises(DataMasqueApiError, match="without an `id`"): + client.get_discovery_config_library_by_name("my_library", "org") + + +def test_get_discovery_config_library_by_name_not_found(client: DataMasqueClient) -> None: + empty_response = {"count": 0, "next": None, "previous": None, "results": []} + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=empty_response, + status_code=200, + ) + library = client.get_discovery_config_library_by_name("nonexistent") + + assert library is None + + +def test_create_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: + create_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/discovery/config-libraries/", + json=create_response, + status_code=201, + ) + result = client.create_discovery_config_library(config_library) + + assert result is config_library + assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + assert result.is_valid is ValidationStatus.valid + 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 request_body["config_yaml"] == "labels: []\nmetadata_rules: []\nidd_rules: []\n" + + +def test_update_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: + config_library.id = DiscoveryConfigLibraryId(LIBRARY_ID_1) + + update_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-02T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.put( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.update_discovery_config_library(config_library) + + assert result is config_library + assert result.is_valid is ValidationStatus.valid + 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" + + +def test_update_discovery_config_library_no_id_raises( + client: DataMasqueClient, config_library: DiscoveryConfigLibrary +) -> None: + with pytest.raises(ValueError, match="id is None"): + client.update_discovery_config_library(config_library) + + +def test_create_or_update_discovery_config_library_create( + client: DataMasqueClient, config_library: DiscoveryConfigLibrary +) -> None: + empty_list = {"count": 0, "next": None, "previous": None, "results": []} + create_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=empty_list, + status_code=200, + ) + m.post( + "http://test-server/api/discovery/config-libraries/", + json=create_response, + status_code=201, + ) + result = client.create_or_update_discovery_config_library(config_library) + + assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + # Should have called GET (list for name lookup) then POST (create) + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "POST" + + +def test_create_or_update_discovery_config_library_update( + client: DataMasqueClient, config_library: DiscoveryConfigLibrary +) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + }, + ], + } + detail_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + update_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-02T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=list_response, + status_code=200, + ) + m.get( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=detail_response, + status_code=200, + ) + m.put( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.create_or_update_discovery_config_library(config_library) + + assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + # Should have called GET (list), GET (detail), then PUT (update) + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "GET" + assert m.request_history[2].method == "PUT" + + +def test_delete_discovery_config_library_by_id(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_discovery_config_library_by_id_if_exists(DiscoveryConfigLibraryId(LIBRARY_ID_1)) + + assert m.call_count == 1 + + +def test_delete_discovery_config_library_by_id_not_found(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + status_code=404, + ) + # Should not raise + client.delete_discovery_config_library_by_id_if_exists(DiscoveryConfigLibraryId(LIBRARY_ID_1)) + + +def test_delete_discovery_config_library_by_id_force(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_discovery_config_library_by_id_if_exists(DiscoveryConfigLibraryId(LIBRARY_ID_1), force=True) + + assert "force=true" in m.last_request.url + + +def test_delete_discovery_config_library_by_name( + client: DataMasqueClient, sample_library_list_response: dict[str, Any] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=sample_library_list_response, + status_code=200, + ) + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_discovery_config_library_by_name_if_exists("my_library", "org") + + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + + +def test_delete_discovery_config_library_by_name_not_found( + client: DataMasqueClient, sample_library_list_response: dict[str, Any] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=sample_library_list_response, + status_code=200, + ) + client.delete_discovery_config_library_by_name_if_exists("nonexistent") + + # Only the list call should have been made, no DELETE + assert m.call_count == 1 + assert m.request_history[0].method == "GET" From e7aed5431a030019c7dcc449da8ee389a92cd6de Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Tue, 7 Jul 2026 13:49:29 +1200 Subject: [PATCH 2/4] fix: add discover-conf-library to init, handle non pagination --- datamasque/client/__init__.py | 3 + .../client/discovery_config_libraries.py | 122 +++-- .../client/models/discovery_config_library.py | 9 +- tests/test_discovery_config_libraries.py | 503 ++++++++++++------ 4 files changed, 432 insertions(+), 205 deletions(-) diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index a3ae3b1..8a925e4 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -80,6 +80,7 @@ TableConstraints, ) from datamasque.client.models.discovery_config import DiscoveryConfig, DiscoveryConfigId, DiscoveryConfigType +from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId from datamasque.client.models.dm_instance import DataMasqueInstanceConfig from datamasque.client.models.files import ( DataMasqueFile, @@ -148,6 +149,8 @@ "DatabricksConnectionConfig", "DiscoveryConfig", "DiscoveryConfigId", + "DiscoveryConfigLibrary", + "DiscoveryConfigLibraryId", "DiscoveryConfigNotFoundError", "DiscoveryConfigType", "DiscoveryMatch", diff --git a/datamasque/client/discovery_config_libraries.py b/datamasque/client/discovery_config_libraries.py index f3dd47a..69def12 100644 --- a/datamasque/client/discovery_config_libraries.py +++ b/datamasque/client/discovery_config_libraries.py @@ -1,10 +1,10 @@ import logging -from typing import Iterator, Optional +from typing import Optional from datamasque.client.base import BaseClient from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.discovery_config import DiscoveryConfigType from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId -from datamasque.client.models.pagination import Page logger = logging.getLogger(__name__) @@ -12,20 +12,18 @@ class DiscoveryConfigLibraryClient(BaseClient): """Discovery config library CRUD API methods. Mixed into `DataMasqueClient`.""" - def iter_discovery_config_libraries(self) -> Iterator[DiscoveryConfigLibrary]: - """Lazily iterate all discovery config libraries via the paginated endpoint.""" - - return self._iter_paginated("/api/discovery/config-libraries/", model=DiscoveryConfigLibrary) - def list_discovery_config_libraries(self) -> list[DiscoveryConfigLibrary]: """ Lists all (non-archived) discovery config libraries. + Unlike most list endpoints, this one is not paginated; + the server returns every library in a single response. Note: the YAML content is not included in the list response for performance. Use `get_discovery_config_library` to retrieve the full library with its YAML body. """ - return list(self.iter_discovery_config_libraries()) + response = self.make_request("GET", "/api/discovery/config-libraries/") + return [DiscoveryConfigLibrary.model_validate(item) for item in response.json()] def get_discovery_config_library(self, library_id: DiscoveryConfigLibraryId) -> DiscoveryConfigLibrary: """Retrieves a single discovery config library by ID, including its YAML content.""" @@ -33,36 +31,73 @@ def get_discovery_config_library(self, library_id: DiscoveryConfigLibraryId) -> response = self.make_request("GET", f"/api/discovery/config-libraries/{library_id}/") return DiscoveryConfigLibrary.model_validate(response.json()) - def get_discovery_config_library_by_name(self, name: str, namespace: str = "") -> Optional[DiscoveryConfigLibrary]: + def find_discovery_config_library_ids( + self, name: str, namespace: str, config_type: Optional[DiscoveryConfigType] + ) -> list[DiscoveryConfigLibraryId]: """ - Looks for a discovery config library matching the given name and namespace (case-sensitive, exact match). + Returns the IDs of libraries matching the given name, namespace, and (optionally) config type. - Returns it (with full YAML content) if found, otherwise `None`. + The name is filtered server-side via `name_exact` to keep the response small, + but all three fields are matched client-side: + the server's `namespace_exact` filter ignores empty strings (the default namespace), + and delete-by-name must not trust a filter param the server may ignore. """ response = self.make_request( "GET", "/api/discovery/config-libraries/", - params={"name_exact": name, "namespace_exact": namespace, "limit": 1}, + params={"name_exact": name}, ) - page = Page[DiscoveryConfigLibrary].model_validate(response.json()) - if not page.results: + entries = [DiscoveryConfigLibrary.model_validate(item) for item in response.json()] + matches = [ + entry + for entry in entries + if entry.name == name + and entry.namespace == namespace + and (config_type is None or entry.config_type is config_type) + ] + + library_ids = [] + for entry in matches: + if entry.id is None: + raise DataMasqueApiError( + "Server returned a discovery config library list entry without an `id`.", + response=response, + ) + library_ids.append(entry.id) + + return library_ids + + def get_discovery_config_library_by_name( + self, name: str, namespace: str = "", config_type: Optional[DiscoveryConfigType] = None + ) -> Optional[DiscoveryConfigLibrary]: + """ + Looks for a discovery config library matching the given name and namespace (case-sensitive, exact match). + + Returns it (with full YAML content) if found, otherwise `None`. + + A database and a file library may share a name; + pass `config_type` to disambiguate. + Raises `DataMasqueException` if `config_type` was not given and both exist. + """ + + library_ids = self.find_discovery_config_library_ids(name, namespace, config_type) + if not library_ids: return None - library_id = page.results[0].id - if library_id is None: - raise DataMasqueApiError( - "Server returned a discovery config library list entry without an `id`.", - response=response, + if len(library_ids) > 1: + raise DataMasqueException( + f'Multiple discovery config libraries are named "{name}"; pass `config_type` to disambiguate.' ) - return self.get_discovery_config_library(library_id) + return self.get_discovery_config_library(library_ids[0]) def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: """ Creates a new discovery config library on the server. - Sets the library's server-assigned fields (`id`, `is_valid`, `created`, `modified`) and returns the library. + Sets the library's server-assigned fields + (`id`, `is_valid`, `validation_error`, `created`, `modified`) and returns the library. """ data = library.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -70,6 +105,7 @@ def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di created = DiscoveryConfigLibrary.model_validate(response.json()) library.id = created.id library.is_valid = created.is_valid + library.validation_error = created.validation_error library.created = created.created library.modified = created.modified logger.info('Creation of discovery config library "%s" successful', library.name) @@ -79,30 +115,39 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di """ Performs a full update of the discovery config library. - The library must have its `id` set (i.e., it must have been previously created or retrieved from the server). + The library must have its `id` set (i.e., it must have been previously created or retrieved from the server) + and its `yaml` content present. + A library's `config_type` is fixed at creation and cannot be changed by an update. """ 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: + raise ValueError( + "Cannot update a discovery config library without YAML content (yaml is None); " + "list results omit YAML, so fetch the full library with `get_discovery_config_library` first" + ) + data = library.model_dump(exclude_none=True, by_alias=True, mode="json") response = self.make_request("PUT", f"/api/discovery/config-libraries/{library.id}/", data=data) updated = DiscoveryConfigLibrary.model_validate(response.json()) library.is_valid = updated.is_valid + library.validation_error = updated.validation_error library.modified = updated.modified logger.debug('Update of discovery config library "%s" successful', library.name) return library def create_or_update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: """ - Creates the library if it doesn't exist, or updates it if one with the same name and namespace already exists. + Creates the library, or updates the existing one with the same name, namespace, and config type. Sets the library's `id` property. """ - existing = self.get_discovery_config_library_by_name(library.name, library.namespace) - if existing is not None: - library.id = existing.id + library_ids = self.find_discovery_config_library_ids(library.name, library.namespace, library.config_type) + if library_ids: + library.id = library_ids[0] return self.update_discovery_config_library(library) return self.create_discovery_config_library(library) @@ -123,20 +168,21 @@ def delete_discovery_config_library_by_id_if_exists( self._delete_if_exists(f"/api/discovery/config-libraries/{library_id}/", params=params) def delete_discovery_config_library_by_name_if_exists( - self, name: str, namespace: str = "", *, force: bool = False + self, + name: str, + namespace: str = "", + *, + config_type: Optional[DiscoveryConfigType] = None, + force: bool = False, ) -> None: """ - Deletes the discovery config library with the given name and namespace. + Deletes the discovery config library(s) with the given name and namespace. - No-op if the library does not exist. - """ + No-op if no library matches. - all_libraries = self.list_discovery_config_libraries() - matching = [lib for lib in all_libraries if lib.name == name and lib.namespace == namespace] - for lib in matching: - if lib.id is None: - raise DataMasqueException( - f'Server returned a discovery config library named "{lib.name}" without an `id`.' - ) + When `config_type` is not given and both a database and a file library share the name, + both are deleted. + """ - self.delete_discovery_config_library_by_id_if_exists(lib.id, force=force) + for library_id in self.find_discovery_config_library_ids(name, namespace, config_type): + self.delete_discovery_config_library_by_id_if_exists(library_id, force=force) diff --git a/datamasque/client/models/discovery_config_library.py b/datamasque/client/models/discovery_config_library.py index 1132a63..0608d5e 100644 --- a/datamasque/client/models/discovery_config_library.py +++ b/datamasque/client/models/discovery_config_library.py @@ -3,17 +3,24 @@ from pydantic import BaseModel, ConfigDict, Field +from datamasque.client.models.discovery_config import DiscoveryConfigType from datamasque.client.models.status import ValidationStatus DiscoveryConfigLibraryId = NewType("DiscoveryConfigLibraryId", str) class DiscoveryConfigLibrary(BaseModel): - """Represents a named, namespaced, persisted YAML discovery config library.""" + """ + Represents a named, namespaced, persisted YAML discovery config library. + + A database and a file library may share a name; + uniqueness on the server is scoped to (`namespace`, `name`, `config_type`). + """ model_config = ConfigDict(extra="allow", populate_by_name=True) name: str + config_type: DiscoveryConfigType namespace: str = "" yaml: Optional[str] = Field(default=None, alias="config_yaml") id: Optional[DiscoveryConfigLibraryId] = None diff --git a/tests/test_discovery_config_libraries.py b/tests/test_discovery_config_libraries.py index 6c23202..f8922c4 100644 --- a/tests/test_discovery_config_libraries.py +++ b/tests/test_discovery_config_libraries.py @@ -5,45 +5,48 @@ import pytest import requests_mock +from pydantic import ValidationError -from datamasque.client import DataMasqueClient -from datamasque.client.exceptions import DataMasqueApiError -from datamasque.client.models.discovery_config_library import ( +from datamasque.client import ( + DataMasqueClient, DiscoveryConfigLibrary, DiscoveryConfigLibraryId, - ValidationStatus, + DiscoveryConfigType, ) +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.status import ValidationStatus LIBRARY_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" LIBRARY_ID_2 = "bbbbbbbb-1111-2222-3333-444444444444" @pytest.fixture -def sample_library_list_response() -> dict[str, Any]: - """Paginated list response (without config_yaml).""" - return { - "count": 2, - "next": None, - "previous": None, - "results": [ - { - "id": LIBRARY_ID_1, - "name": "my_library", - "namespace": "org", - "is_valid": "valid", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-02T12:00:00Z", - }, - { - "id": LIBRARY_ID_2, - "name": "another_library", - "namespace": "", - "is_valid": "invalid", - "created": "2025-02-01T12:00:00Z", - "modified": "2025-02-02T12:00:00Z", - }, - ], - } +def sample_library_list_response() -> list[dict[str, Any]]: + """List response: a bare (unpaginated) array of entries without `config_yaml`.""" + return [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_type": "database", + "is_valid": "valid", + "validation_error": None, + "usage_count": 0, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "another_library", + "namespace": "", + "config_type": "file", + "is_valid": "invalid", + "validation_error": "bad yaml", + "usage_count": 2, + "created": "2025-02-01T12:00:00Z", + "modified": "2025-02-02T12:00:00Z", + }, + ] @pytest.fixture @@ -53,25 +56,58 @@ def sample_library_detail_response() -> dict[str, Any]: "id": LIBRARY_ID_1, "name": "my_library", "namespace": "org", + "config_type": "database", "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", "is_valid": "valid", "validation_error": None, + "usage_count": 0, "created": "2025-01-01T12:00:00Z", "modified": "2025-01-02T12:00:00Z", } +@pytest.fixture +def same_name_both_config_types() -> list[dict[str, Any]]: + """Two libraries sharing (name, namespace) but with different config types, as the server allows.""" + return [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "", + "config_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "my_library", + "namespace": "", + "config_type": "file", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ] + + @pytest.fixture def config_library() -> DiscoveryConfigLibrary: return DiscoveryConfigLibrary( name="test_library", namespace="test_ns", + config_type=DiscoveryConfigType.database, yaml="labels: []\nmetadata_rules: []\nidd_rules: []\n", ) +def test_config_type_is_required_on_the_model() -> None: + with pytest.raises(ValidationError, match="config_type"): + DiscoveryConfigLibrary(name="test_library", yaml="labels: []\n") + + def test_list_discovery_config_libraries( - client: DataMasqueClient, sample_library_list_response: dict[str, Any] + client: DataMasqueClient, sample_library_list_response: list[dict[str, Any]] ) -> None: with requests_mock.Mocker() as m: m.get( @@ -85,73 +121,21 @@ def test_list_discovery_config_libraries( assert libraries[0].id == DiscoveryConfigLibraryId(LIBRARY_ID_1) assert libraries[0].name == "my_library" assert libraries[0].namespace == "org" + assert libraries[0].config_type is DiscoveryConfigType.database assert libraries[0].yaml is None assert libraries[0].is_valid is ValidationStatus.valid assert libraries[1].id == DiscoveryConfigLibraryId(LIBRARY_ID_2) assert libraries[1].name == "another_library" + assert libraries[1].config_type is DiscoveryConfigType.file assert libraries[1].is_valid is ValidationStatus.invalid - - -def test_list_discovery_config_libraries_pagination(client: DataMasqueClient) -> None: - page1 = { - "count": 3, - "next": "http://test-server/api/discovery/config-libraries/?limit=2&offset=2", - "previous": None, - "results": [ - { - "id": LIBRARY_ID_1, - "name": "lib1", - "namespace": "", - "is_valid": "valid", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-01T12:00:00Z", - }, - { - "id": LIBRARY_ID_2, - "name": "lib2", - "namespace": "", - "is_valid": "valid", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-01T12:00:00Z", - }, - ], - } - page2 = { - "count": 3, - "next": None, - "previous": "http://test-server/api/discovery/config-libraries/?limit=2", - "results": [ - { - "id": "cccccccc-1111-2222-3333-444444444444", - "name": "lib3", - "namespace": "", - "is_valid": "unknown", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-01T12:00:00Z", - }, - ], - } - - with requests_mock.Mocker() as m: - m.get( - "http://test-server/api/discovery/config-libraries/", - [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], - ) - libraries = client.list_discovery_config_libraries() - - assert len(libraries) == 3 - assert libraries[0].name == "lib1" - assert libraries[1].name == "lib2" - assert libraries[2].name == "lib3" + assert libraries[1].validation_error == "bad yaml" def test_list_discovery_config_libraries_empty(client: DataMasqueClient) -> None: - empty_response = {"count": 0, "next": None, "previous": None, "results": []} - with requests_mock.Mocker() as m: m.get( "http://test-server/api/discovery/config-libraries/", - json=empty_response, + json=[], status_code=200, ) libraries = client.list_discovery_config_libraries() @@ -171,33 +155,20 @@ def test_get_discovery_config_library(client: DataMasqueClient, sample_library_d assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) assert library.name == "my_library" assert library.namespace == "org" + assert library.config_type is DiscoveryConfigType.database assert library.yaml == "labels: []\nmetadata_rules: []\nidd_rules: []\n" assert library.is_valid is ValidationStatus.valid def test_get_discovery_config_library_by_name_found( - client: DataMasqueClient, sample_library_detail_response: dict[str, Any] + client: DataMasqueClient, + sample_library_list_response: list[dict[str, Any]], + sample_library_detail_response: dict[str, Any], ) -> None: - list_response = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": LIBRARY_ID_1, - "name": "my_library", - "namespace": "org", - "is_valid": "valid", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-02T12:00:00Z", - }, - ], - } - with requests_mock.Mocker() as m: m.get( "http://test-server/api/discovery/config-libraries/", - json=list_response, + json=[sample_library_list_response[0]], status_code=200, ) m.get( @@ -211,43 +182,70 @@ def test_get_discovery_config_library_by_name_found( assert library.name == "my_library" assert library.yaml == "labels: []\nmetadata_rules: []\nidd_rules: []\n" assert "name_exact=my_library" in m.request_history[0].url - assert "namespace_exact=org" in m.request_history[0].url -def test_get_discovery_config_library_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: - """If the server returns a list entry without `id`, the by-name lookup surfaces a typed API error.""" - list_response_without_id = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "name": "my_library", - "namespace": "org", - "is_valid": "valid", - "created": "2025-01-01T12:00:00Z", - "modified": "2025-01-02T12:00:00Z", - }, - ], - } +def test_get_discovery_config_library_by_name_matches_namespace_client_side( + client: DataMasqueClient, sample_library_detail_response: dict[str, Any] +) -> None: + """The server's `namespace_exact` filter ignores empty strings, so namespace matching happens client-side.""" + same_name_two_namespaces = [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "my_library", + "namespace": "", + "config_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ] with requests_mock.Mocker() as m: m.get( "http://test-server/api/discovery/config-libraries/", - json=list_response_without_id, + json=same_name_two_namespaces, status_code=200, ) - with pytest.raises(DataMasqueApiError, match="without an `id`"): - client.get_discovery_config_library_by_name("my_library", "org") + m.get( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + json=sample_library_detail_response, + status_code=200, + ) + library = client.get_discovery_config_library_by_name("my_library", "org") + + assert library is not None + assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) -def test_get_discovery_config_library_by_name_not_found(client: DataMasqueClient) -> None: - empty_response = {"count": 0, "next": None, "previous": None, "results": []} +def test_get_discovery_config_library_by_name_default_namespace_does_not_match_other_namespaces( + client: DataMasqueClient, sample_library_list_response: list[dict[str, Any]] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=[sample_library_list_response[0]], + status_code=200, + ) + library = client.get_discovery_config_library_by_name("my_library") + + assert library is None + assert m.call_count == 1 + +def test_get_discovery_config_library_by_name_not_found(client: DataMasqueClient) -> None: with requests_mock.Mocker() as m: m.get( "http://test-server/api/discovery/config-libraries/", - json=empty_response, + json=[], status_code=200, ) library = client.get_discovery_config_library_by_name("nonexistent") @@ -255,13 +253,83 @@ def test_get_discovery_config_library_by_name_not_found(client: DataMasqueClient assert library is None +def test_get_discovery_config_library_by_name_ambiguous_config_type_raises( + client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=same_name_both_config_types, + status_code=200, + ) + with pytest.raises(DataMasqueException, match="config_type"): + client.get_discovery_config_library_by_name("my_library") + + +def test_get_discovery_config_library_by_name_config_type_disambiguates( + client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] +) -> None: + detail_response = { + "id": LIBRARY_ID_2, + "name": "my_library", + "namespace": "", + "config_type": "file", + "config_yaml": "labels: []\n", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=same_name_both_config_types, + status_code=200, + ) + m.get( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", + json=detail_response, + status_code=200, + ) + library = client.get_discovery_config_library_by_name("my_library", config_type=DiscoveryConfigType.file) + + assert library is not None + assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_2) + assert library.config_type is DiscoveryConfigType.file + + +def test_get_discovery_config_library_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: + """If the server returns a list entry without `id`, the by-name lookup surfaces a typed API error.""" + list_response_without_id = [ + { + "name": "my_library", + "namespace": "org", + "config_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ] + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=list_response_without_id, + status_code=200, + ) + with pytest.raises(DataMasqueApiError, match="without an `id`"): + client.get_discovery_config_library_by_name("my_library", "org") + + def test_create_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: create_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", + "config_type": "database", "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", "is_valid": "valid", + "validation_error": None, "created": "2025-06-01T10:00:00Z", "modified": "2025-06-01T10:00:00Z", } @@ -283,18 +351,49 @@ def test_create_discovery_config_library(client: DataMasqueClient, config_librar request_body = m.last_request.json() assert request_body["name"] == "test_library" assert request_body["namespace"] == "test_ns" + assert request_body["config_type"] == "database" assert request_body["config_yaml"] == "labels: []\nmetadata_rules: []\nidd_rules: []\n" +def test_create_discovery_config_library_reports_validation_error( + client: DataMasqueClient, config_library: DiscoveryConfigLibrary +) -> None: + create_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_type": "database", + "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", + "is_valid": "invalid", + "validation_error": "metadata_rules is not a list", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/discovery/config-libraries/", + json=create_response, + status_code=201, + ) + result = client.create_discovery_config_library(config_library) + + assert result.is_valid is ValidationStatus.invalid + assert result.validation_error == "metadata_rules is not a list" + + def test_update_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: config_library.id = DiscoveryConfigLibraryId(LIBRARY_ID_1) + config_library.validation_error = "stale error from a previous update" update_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", + "config_type": "database", "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", "is_valid": "valid", + "validation_error": None, "created": "2025-06-01T10:00:00Z", "modified": "2025-06-02T10:00:00Z", } @@ -309,6 +408,7 @@ 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.modified == datetime.fromisoformat("2025-06-02T10:00:00+00:00") request_body = m.last_request.json() @@ -323,16 +423,27 @@ def test_update_discovery_config_library_no_id_raises( client.update_discovery_config_library(config_library) +def test_update_discovery_config_library_without_yaml_raises( + client: DataMasqueClient, config_library: DiscoveryConfigLibrary +) -> None: + config_library.id = DiscoveryConfigLibraryId(LIBRARY_ID_1) + config_library.yaml = None + + with pytest.raises(ValueError, match="yaml is None"): + client.update_discovery_config_library(config_library) + + def test_create_or_update_discovery_config_library_create( client: DataMasqueClient, config_library: DiscoveryConfigLibrary ) -> None: - empty_list = {"count": 0, "next": None, "previous": None, "results": []} create_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", + "config_type": "database", "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", "is_valid": "valid", + "validation_error": None, "created": "2025-06-01T10:00:00Z", "modified": "2025-06-01T10:00:00Z", } @@ -340,7 +451,7 @@ def test_create_or_update_discovery_config_library_create( with requests_mock.Mocker() as m: m.get( "http://test-server/api/discovery/config-libraries/", - json=empty_list, + json=[], status_code=200, ) m.post( @@ -351,44 +462,33 @@ def test_create_or_update_discovery_config_library_create( result = client.create_or_update_discovery_config_library(config_library) assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) - # Should have called GET (list for name lookup) then POST (create) assert m.request_history[0].method == "GET" + assert "name_exact=test_library" in m.request_history[0].url assert m.request_history[1].method == "POST" def test_create_or_update_discovery_config_library_update( client: DataMasqueClient, config_library: DiscoveryConfigLibrary ) -> None: - list_response = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": LIBRARY_ID_1, - "name": "test_library", - "namespace": "test_ns", - "is_valid": "valid", - "created": "2025-06-01T10:00:00Z", - "modified": "2025-06-01T10:00:00Z", - }, - ], - } - detail_response = { - "id": LIBRARY_ID_1, - "name": "test_library", - "namespace": "test_ns", - "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", - "is_valid": "valid", - "created": "2025-06-01T10:00:00Z", - "modified": "2025-06-01T10:00:00Z", - } + list_response = [ + { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_type": "database", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + }, + ] update_response = { "id": LIBRARY_ID_1, "name": "test_library", "namespace": "test_ns", + "config_type": "database", "config_yaml": "labels: []\nmetadata_rules: []\nidd_rules: []\n", "is_valid": "valid", + "validation_error": None, "created": "2025-06-01T10:00:00Z", "modified": "2025-06-02T10:00:00Z", } @@ -399,11 +499,6 @@ def test_create_or_update_discovery_config_library_update( json=list_response, status_code=200, ) - m.get( - f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", - json=detail_response, - status_code=200, - ) m.put( f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", json=update_response, @@ -412,10 +507,45 @@ def test_create_or_update_discovery_config_library_update( result = client.create_or_update_discovery_config_library(config_library) assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) - # Should have called GET (list), GET (detail), then PUT (update) + assert m.call_count == 2 assert m.request_history[0].method == "GET" - assert m.request_history[1].method == "GET" - assert m.request_history[2].method == "PUT" + assert m.request_history[1].method == "PUT" + + +def test_create_or_update_discovery_config_library_matches_config_type( + client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] +) -> None: + file_library = DiscoveryConfigLibrary( + name="my_library", + config_type=DiscoveryConfigType.file, + yaml="labels: []\n", + ) + update_response = { + "id": LIBRARY_ID_2, + "name": "my_library", + "namespace": "", + "config_type": "file", + "config_yaml": "labels: []\n", + "is_valid": "valid", + "validation_error": None, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-03T12:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=same_name_both_config_types, + status_code=200, + ) + m.put( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", + json=update_response, + status_code=200, + ) + result = client.create_or_update_discovery_config_library(file_library) + + assert result.id == DiscoveryConfigLibraryId(LIBRARY_ID_2) def test_delete_discovery_config_library_by_id(client: DataMasqueClient) -> None: @@ -435,7 +565,6 @@ def test_delete_discovery_config_library_by_id_not_found(client: DataMasqueClien f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", status_code=404, ) - # Should not raise client.delete_discovery_config_library_by_id_if_exists(DiscoveryConfigLibraryId(LIBRARY_ID_1)) @@ -451,7 +580,7 @@ def test_delete_discovery_config_library_by_id_force(client: DataMasqueClient) - def test_delete_discovery_config_library_by_name( - client: DataMasqueClient, sample_library_list_response: dict[str, Any] + client: DataMasqueClient, sample_library_list_response: list[dict[str, Any]] ) -> None: with requests_mock.Mocker() as m: m.get( @@ -466,11 +595,54 @@ def test_delete_discovery_config_library_by_name( client.delete_discovery_config_library_by_name_if_exists("my_library", "org") assert m.request_history[0].method == "GET" + assert "name_exact=my_library" in m.request_history[0].url + assert m.request_history[1].method == "DELETE" + + +def test_delete_discovery_config_library_by_name_deletes_both_config_types( + client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=same_name_both_config_types, + status_code=200, + ) + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", + status_code=204, + ) + client.delete_discovery_config_library_by_name_if_exists("my_library") + + assert [request.method for request in m.request_history] == ["GET", "DELETE", "DELETE"] + + +def test_delete_discovery_config_library_by_name_config_type_deletes_only_that_type( + client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=same_name_both_config_types, + status_code=200, + ) + m.delete( + f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", + status_code=204, + ) + client.delete_discovery_config_library_by_name_if_exists("my_library", config_type=DiscoveryConfigType.file) + + assert m.call_count == 2 assert m.request_history[1].method == "DELETE" + assert LIBRARY_ID_2 in m.request_history[1].url def test_delete_discovery_config_library_by_name_not_found( - client: DataMasqueClient, sample_library_list_response: dict[str, Any] + client: DataMasqueClient, sample_library_list_response: list[dict[str, Any]] ) -> None: with requests_mock.Mocker() as m: m.get( @@ -480,6 +652,5 @@ def test_delete_discovery_config_library_by_name_not_found( ) client.delete_discovery_config_library_by_name_if_exists("nonexistent") - # Only the list call should have been made, no DELETE assert m.call_count == 1 assert m.request_history[0].method == "GET" From 5f2bbee0b049d12c70fc13dfff2b44f161d8b97f Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Tue, 7 Jul 2026 15:06:34 +1200 Subject: [PATCH 3/4] chore: sync uv.lock to version 1.1.5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 38ff7b9..f83e0e0 100644 --- a/uv.lock +++ b/uv.lock @@ -428,7 +428,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.1.4" +version = "1.1.5" source = { editable = "." } dependencies = [ { name = "pydantic" }, From 4f06c461acb4482d06c302df49bd95500f4e778c Mon Sep 17 00:00:00 2001 From: Andrew Chester Date: Wed, 8 Jul 2026 08:44:29 +1200 Subject: [PATCH 4/4] fix(discovery): DM-3801 address review (require config_type, exclude read-only fields, wording, version bump) --- .../client/discovery_config_libraries.py | 88 ++++++++----------- .../client/models/discovery_config_library.py | 20 ++--- pyproject.toml | 2 +- setup.cfg | 2 +- tests/test_discovery_config_libraries.py | 63 ++++--------- uv.lock | 2 +- 6 files changed, 65 insertions(+), 112 deletions(-) diff --git a/datamasque/client/discovery_config_libraries.py b/datamasque/client/discovery_config_libraries.py index 69def12..d657b3c 100644 --- a/datamasque/client/discovery_config_libraries.py +++ b/datamasque/client/discovery_config_libraries.py @@ -2,7 +2,7 @@ from typing import Optional from datamasque.client.base import BaseClient -from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.exceptions import DataMasqueApiError from datamasque.client.models.discovery_config import DiscoveryConfigType from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId @@ -14,7 +14,7 @@ class DiscoveryConfigLibraryClient(BaseClient): def list_discovery_config_libraries(self) -> list[DiscoveryConfigLibrary]: """ - Lists all (non-archived) discovery config libraries. + Lists all discovery config libraries. Unlike most list endpoints, this one is not paginated; the server returns every library in a single response. @@ -31,16 +31,15 @@ def get_discovery_config_library(self, library_id: DiscoveryConfigLibraryId) -> response = self.make_request("GET", f"/api/discovery/config-libraries/{library_id}/") return DiscoveryConfigLibrary.model_validate(response.json()) - def find_discovery_config_library_ids( - self, name: str, namespace: str, config_type: Optional[DiscoveryConfigType] - ) -> list[DiscoveryConfigLibraryId]: + def _get_discovery_config_library_id_by_name( + self, name: str, config_type: DiscoveryConfigType, namespace: str + ) -> Optional[DiscoveryConfigLibraryId]: """ - Returns the IDs of libraries matching the given name, namespace, and (optionally) config type. + Returns the ID of the library with the given name, type, and namespace, or `None` if there is none. - The name is filtered server-side via `name_exact` to keep the response small, - but all three fields are matched client-side: - the server's `namespace_exact` filter ignores empty strings (the default namespace), - and delete-by-name must not trust a filter param the server may ignore. + The listing is filtered by name to keep the response small; + type and namespace are matched client-side, + since the API's namespace filter cannot select the default (empty) namespace. """ response = self.make_request( @@ -52,45 +51,35 @@ def find_discovery_config_library_ids( matches = [ entry for entry in entries - if entry.name == name - and entry.namespace == namespace - and (config_type is None or entry.config_type is config_type) + if entry.name == name and entry.namespace == namespace and entry.config_type is config_type ] + if not matches: + return None - library_ids = [] - for entry in matches: - if entry.id is None: - raise DataMasqueApiError( - "Server returned a discovery config library list entry without an `id`.", - response=response, - ) - library_ids.append(entry.id) + if matches[0].id is None: + raise DataMasqueApiError( + "Server returned a discovery config library list entry without an `id`.", + response=response, + ) - return library_ids + return matches[0].id def get_discovery_config_library_by_name( - self, name: str, namespace: str = "", config_type: Optional[DiscoveryConfigType] = None + self, name: str, config_type: DiscoveryConfigType, namespace: str = "" ) -> Optional[DiscoveryConfigLibrary]: """ - Looks for a discovery config library matching the given name and namespace (case-sensitive, exact match). + Looks for a discovery config library matching the given name, type, and namespace (case-sensitive, exact match). + Library names are unique per type within a namespace, + so a type is required to identify a single library. Returns it (with full YAML content) if found, otherwise `None`. - - A database and a file library may share a name; - pass `config_type` to disambiguate. - Raises `DataMasqueException` if `config_type` was not given and both exist. """ - library_ids = self.find_discovery_config_library_ids(name, namespace, config_type) - if not library_ids: + library_id = self._get_discovery_config_library_id_by_name(name, config_type, namespace) + if library_id is None: return None - if len(library_ids) > 1: - raise DataMasqueException( - f'Multiple discovery config libraries are named "{name}"; pass `config_type` to disambiguate.' - ) - - return self.get_discovery_config_library(library_ids[0]) + return self.get_discovery_config_library(library_id) def create_discovery_config_library(self, library: DiscoveryConfigLibrary) -> DiscoveryConfigLibrary: """ @@ -145,9 +134,9 @@ def create_or_update_discovery_config_library(self, library: DiscoveryConfigLibr Sets the library's `id` property. """ - library_ids = self.find_discovery_config_library_ids(library.name, library.namespace, library.config_type) - if library_ids: - library.id = library_ids[0] + library_id = self._get_discovery_config_library_id_by_name(library.name, library.config_type, library.namespace) + if library_id is not None: + library.id = library_id return self.update_discovery_config_library(library) return self.create_discovery_config_library(library) @@ -156,7 +145,7 @@ def delete_discovery_config_library_by_id_if_exists( self, library_id: DiscoveryConfigLibraryId, *, force: bool = False ) -> None: """ - Deletes (archives) the discovery config library with the given ID. + Deletes the discovery config library with the given ID. No-op if the library does not exist. @@ -168,21 +157,16 @@ def delete_discovery_config_library_by_id_if_exists( self._delete_if_exists(f"/api/discovery/config-libraries/{library_id}/", params=params) def delete_discovery_config_library_by_name_if_exists( - self, - name: str, - namespace: str = "", - *, - config_type: Optional[DiscoveryConfigType] = None, - force: bool = False, + self, name: str, config_type: DiscoveryConfigType, namespace: str = "", *, force: bool = False ) -> None: """ - Deletes the discovery config library(s) with the given name and namespace. - - No-op if no library matches. + Deletes the discovery config library with the given name, type, and namespace. - When `config_type` is not given and both a database and a file library share the name, - both are deleted. + Library names are unique per type within a namespace, + so a type is required to identify a single library. + No-op if no such library exists. """ - for library_id in self.find_discovery_config_library_ids(name, namespace, config_type): + library_id = self._get_discovery_config_library_id_by_name(name, config_type, namespace) + if library_id is not None: self.delete_discovery_config_library_by_id_if_exists(library_id, force=force) diff --git a/datamasque/client/models/discovery_config_library.py b/datamasque/client/models/discovery_config_library.py index 0608d5e..095cf18 100644 --- a/datamasque/client/models/discovery_config_library.py +++ b/datamasque/client/models/discovery_config_library.py @@ -13,8 +13,8 @@ class DiscoveryConfigLibrary(BaseModel): """ Represents a named, namespaced, persisted YAML discovery config library. - A database and a file library may share a name; - uniqueness on the server is scoped to (`namespace`, `name`, `config_type`). + Library names are unique per config type within a namespace, + so a database and a file library may share a name. """ model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -23,11 +23,11 @@ class DiscoveryConfigLibrary(BaseModel): config_type: DiscoveryConfigType namespace: str = "" yaml: Optional[str] = Field(default=None, alias="config_yaml") - id: Optional[DiscoveryConfigLibraryId] = None - # Server-managed validation surface, populated by the DataMasque server. - # `is_valid` may be `in_progress` immediately after creating a large library, - # transitioning to `valid` or `invalid` once the server finishes validating. - is_valid: Optional[ValidationStatus] = None - validation_error: Optional[str] = None - created: Optional[datetime] = None - modified: Optional[datetime] = None + # Server-populated read-only fields, excluded from request bodies. + id: Optional[DiscoveryConfigLibraryId] = Field(default=None, exclude=True) + is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) + """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.""" + 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 d892caf..05f77f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "datamasque-python" -version = "1.1.5" +version = "1.1.6" description = "Official Python client for the DataMasque data-masking API." authors = [ { name = "DataMasque Ltd" }, diff --git a/setup.cfg b/setup.cfg index c822e09..59c6791 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.3 +current_version = 1.1.6 commit = True tag = True diff --git a/tests/test_discovery_config_libraries.py b/tests/test_discovery_config_libraries.py index f8922c4..5ad094b 100644 --- a/tests/test_discovery_config_libraries.py +++ b/tests/test_discovery_config_libraries.py @@ -13,7 +13,7 @@ DiscoveryConfigLibraryId, DiscoveryConfigType, ) -from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.exceptions import DataMasqueApiError from datamasque.client.models.status import ValidationStatus LIBRARY_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" @@ -176,7 +176,7 @@ def test_get_discovery_config_library_by_name_found( json=sample_library_detail_response, status_code=200, ) - library = client.get_discovery_config_library_by_name("my_library", "org") + library = client.get_discovery_config_library_by_name("my_library", DiscoveryConfigType.database, "org") assert library is not None assert library.name == "my_library" @@ -220,7 +220,7 @@ def test_get_discovery_config_library_by_name_matches_namespace_client_side( json=sample_library_detail_response, status_code=200, ) - library = client.get_discovery_config_library_by_name("my_library", "org") + library = client.get_discovery_config_library_by_name("my_library", DiscoveryConfigType.database, "org") assert library is not None assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) @@ -235,7 +235,7 @@ def test_get_discovery_config_library_by_name_default_namespace_does_not_match_o json=[sample_library_list_response[0]], status_code=200, ) - library = client.get_discovery_config_library_by_name("my_library") + library = client.get_discovery_config_library_by_name("my_library", DiscoveryConfigType.database) assert library is None assert m.call_count == 1 @@ -248,25 +248,12 @@ def test_get_discovery_config_library_by_name_not_found(client: DataMasqueClient json=[], status_code=200, ) - library = client.get_discovery_config_library_by_name("nonexistent") + library = client.get_discovery_config_library_by_name("nonexistent", DiscoveryConfigType.database) assert library is None -def test_get_discovery_config_library_by_name_ambiguous_config_type_raises( - client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] -) -> None: - with requests_mock.Mocker() as m: - m.get( - "http://test-server/api/discovery/config-libraries/", - json=same_name_both_config_types, - status_code=200, - ) - with pytest.raises(DataMasqueException, match="config_type"): - client.get_discovery_config_library_by_name("my_library") - - -def test_get_discovery_config_library_by_name_config_type_disambiguates( +def test_get_discovery_config_library_by_name_selects_matching_config_type( client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] ) -> None: detail_response = { @@ -291,7 +278,7 @@ def test_get_discovery_config_library_by_name_config_type_disambiguates( json=detail_response, status_code=200, ) - library = client.get_discovery_config_library_by_name("my_library", config_type=DiscoveryConfigType.file) + library = client.get_discovery_config_library_by_name("my_library", DiscoveryConfigType.file) assert library is not None assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_2) @@ -318,7 +305,7 @@ def test_get_discovery_config_library_by_name_raises_when_server_omits_id(client status_code=200, ) with pytest.raises(DataMasqueApiError, match="without an `id`"): - client.get_discovery_config_library_by_name("my_library", "org") + client.get_discovery_config_library_by_name("my_library", DiscoveryConfigType.database, "org") def test_create_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: @@ -353,6 +340,8 @@ def test_create_discovery_config_library(client: DataMasqueClient, config_librar assert request_body["namespace"] == "test_ns" assert request_body["config_type"] == "database" 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() def test_create_discovery_config_library_reports_validation_error( @@ -414,6 +403,8 @@ def test_update_discovery_config_library(client: DataMasqueClient, config_librar 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() def test_update_discovery_config_library_no_id_raises( @@ -592,36 +583,14 @@ def test_delete_discovery_config_library_by_name( f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", status_code=204, ) - client.delete_discovery_config_library_by_name_if_exists("my_library", "org") + client.delete_discovery_config_library_by_name_if_exists("my_library", DiscoveryConfigType.database, "org") assert m.request_history[0].method == "GET" assert "name_exact=my_library" in m.request_history[0].url assert m.request_history[1].method == "DELETE" -def test_delete_discovery_config_library_by_name_deletes_both_config_types( - client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] -) -> None: - with requests_mock.Mocker() as m: - m.get( - "http://test-server/api/discovery/config-libraries/", - json=same_name_both_config_types, - status_code=200, - ) - m.delete( - f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_1}/", - status_code=204, - ) - m.delete( - f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", - status_code=204, - ) - client.delete_discovery_config_library_by_name_if_exists("my_library") - - assert [request.method for request in m.request_history] == ["GET", "DELETE", "DELETE"] - - -def test_delete_discovery_config_library_by_name_config_type_deletes_only_that_type( +def test_delete_discovery_config_library_by_name_deletes_only_matching_config_type( client: DataMasqueClient, same_name_both_config_types: list[dict[str, Any]] ) -> None: with requests_mock.Mocker() as m: @@ -634,7 +603,7 @@ def test_delete_discovery_config_library_by_name_config_type_deletes_only_that_t f"http://test-server/api/discovery/config-libraries/{LIBRARY_ID_2}/", status_code=204, ) - client.delete_discovery_config_library_by_name_if_exists("my_library", config_type=DiscoveryConfigType.file) + client.delete_discovery_config_library_by_name_if_exists("my_library", DiscoveryConfigType.file) assert m.call_count == 2 assert m.request_history[1].method == "DELETE" @@ -650,7 +619,7 @@ def test_delete_discovery_config_library_by_name_not_found( json=sample_library_list_response, status_code=200, ) - client.delete_discovery_config_library_by_name_if_exists("nonexistent") + client.delete_discovery_config_library_by_name_if_exists("nonexistent", DiscoveryConfigType.database) assert m.call_count == 1 assert m.request_history[0].method == "GET" diff --git a/uv.lock b/uv.lock index f83e0e0..7be15ad 100644 --- a/uv.lock +++ b/uv.lock @@ -428,7 +428,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.1.5" +version = "1.1.6" source = { editable = "." } dependencies = [ { name = "pydantic" },