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/__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 new file mode 100644 index 0000000..d657b3c --- /dev/null +++ b/datamasque/client/discovery_config_libraries.py @@ -0,0 +1,172 @@ +import logging +from typing import Optional + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueApiError +from datamasque.client.models.discovery_config import DiscoveryConfigType +from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary, DiscoveryConfigLibraryId + +logger = logging.getLogger(__name__) + + +class DiscoveryConfigLibraryClient(BaseClient): + """Discovery config library CRUD API methods. Mixed into `DataMasqueClient`.""" + + def list_discovery_config_libraries(self) -> list[DiscoveryConfigLibrary]: + """ + Lists all 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. + """ + + 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.""" + + response = self.make_request("GET", f"/api/discovery/config-libraries/{library_id}/") + return DiscoveryConfigLibrary.model_validate(response.json()) + + def _get_discovery_config_library_id_by_name( + self, name: str, config_type: DiscoveryConfigType, namespace: str + ) -> Optional[DiscoveryConfigLibraryId]: + """ + Returns the ID of the library with the given name, type, and namespace, or `None` if there is none. + + 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( + "GET", + "/api/discovery/config-libraries/", + params={"name_exact": name}, + ) + 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 entry.config_type is config_type + ] + if not matches: + return None + + if matches[0].id is None: + raise DataMasqueApiError( + "Server returned a discovery config library list entry without an `id`.", + response=response, + ) + + return matches[0].id + + def get_discovery_config_library_by_name( + self, name: str, config_type: DiscoveryConfigType, namespace: str = "" + ) -> Optional[DiscoveryConfigLibrary]: + """ + 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`. + """ + + library_id = self._get_discovery_config_library_id_by_name(name, config_type, namespace) + if library_id is None: + return None + + 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`, `validation_error`, `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.validation_error = created.validation_error + 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) + 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, or updates the existing one with the same name, namespace, and config type. + + Sets the library's `id` property. + """ + + 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) + + def delete_discovery_config_library_by_id_if_exists( + self, library_id: DiscoveryConfigLibraryId, *, force: bool = False + ) -> None: + """ + Deletes 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, config_type: DiscoveryConfigType, namespace: str = "", *, force: bool = False + ) -> None: + """ + Deletes the discovery config library with the given name, type, and namespace. + + 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. + """ + + 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/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..095cf18 --- /dev/null +++ b/datamasque/client/models/discovery_config_library.py @@ -0,0 +1,33 @@ +from datetime import datetime +from typing import NewType, Optional + +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. + + 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) + + name: str + config_type: DiscoveryConfigType + namespace: str = "" + yaml: Optional[str] = Field(default=None, alias="config_yaml") + # 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 new file mode 100644 index 0000000..5ad094b --- /dev/null +++ b/tests/test_discovery_config_libraries.py @@ -0,0 +1,625 @@ +"""Tests for discovery config library support in the DataMasque client.""" + +from datetime import datetime +from typing import Any + +import pytest +import requests_mock +from pydantic import ValidationError + +from datamasque.client import ( + DataMasqueClient, + DiscoveryConfigLibrary, + DiscoveryConfigLibraryId, + DiscoveryConfigType, +) +from datamasque.client.exceptions import DataMasqueApiError +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() -> 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 +def sample_library_detail_response() -> dict[str, Any]: + """Detail response (with config_yaml).""" + return { + "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: 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, + 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].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 + assert libraries[1].validation_error == "bad yaml" + + +def test_list_discovery_config_libraries_empty(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=[], + 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.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_list_response: list[dict[str, Any]], + sample_library_detail_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[0]], + 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", DiscoveryConfigType.database, "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 + + +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=same_name_two_namespaces, + 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", DiscoveryConfigType.database, "org") + + assert library is not None + assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) + + +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", DiscoveryConfigType.database) + + 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=[], + status_code=200, + ) + library = client.get_discovery_config_library_by_name("nonexistent", DiscoveryConfigType.database) + + assert library is None + + +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 = { + "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", 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", DiscoveryConfigType.database, "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", + } + + 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_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( + 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", + } + + 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.validation_error is None + assert result.modified == datetime.fromisoformat("2025-06-02T10:00:00+00:00") + + request_body = m.last_request.json() + assert request_body["name"] == "test_library" + assert request_body["config_yaml"] == "labels: []\nmetadata_rules: []\nidd_rules: []\n" + read_only_fields = {"id", "is_valid", "validation_error", "created", "modified"} + assert not read_only_fields & request_body.keys() + + +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_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: + 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", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=[], + 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) + 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 = [ + { + "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", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/discovery/config-libraries/", + json=list_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) + assert m.call_count == 2 + assert m.request_history[0].method == "GET" + 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: + 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, + ) + 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: 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, + 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", 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_only_matching_config_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", 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: 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, + status_code=200, + ) + 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 38ff7b9..7be15ad 100644 --- a/uv.lock +++ b/uv.lock @@ -428,7 +428,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.1.4" +version = "1.1.6" source = { editable = "." } dependencies = [ { name = "pydantic" },