diff --git a/HISTORY.rst b/HISTORY.rst index cf9720f..df49383 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,16 @@ History ======= +1.2.0 (2026-07-22) +------------------ + +* Made discovery config libraries untyped, matching the DataMasque 3.26.14 server. A single + library is now identified by ``(namespace, name)`` and may be imported by both database and + file discovery configs. **Breaking:** removed the ``config_type`` field from + ``DiscoveryConfigLibrary`` and the ``config_type`` argument from + ``get_discovery_config_library_by_name``, ``create_or_update_discovery_config_library``, and + ``delete_discovery_config_library_by_name_if_exists``. Requires a 3.26.14 or later server. + 1.1.7 (2026-07-14) ------------------ diff --git a/datamasque/client/discovery_config_libraries.py b/datamasque/client/discovery_config_libraries.py index d657b3c..25c69a3 100644 --- a/datamasque/client/discovery_config_libraries.py +++ b/datamasque/client/discovery_config_libraries.py @@ -3,7 +3,6 @@ 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__) @@ -31,14 +30,12 @@ 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_id_by_name( - self, name: str, config_type: DiscoveryConfigType, namespace: str - ) -> Optional[DiscoveryConfigLibraryId]: + def _get_discovery_config_library_id_by_name(self, name: str, namespace: str) -> Optional[DiscoveryConfigLibraryId]: """ - Returns the ID of the library with the given name, type, and namespace, or `None` if there is none. + Returns the ID of the library with the given name 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, + the namespace is matched client-side, since the API's namespace filter cannot select the default (empty) namespace. """ @@ -48,11 +45,7 @@ def _get_discovery_config_library_id_by_name( 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 - ] + matches = [entry for entry in entries if entry.name == name and entry.namespace == namespace] if not matches: return None @@ -64,18 +57,15 @@ def _get_discovery_config_library_id_by_name( return matches[0].id - def get_discovery_config_library_by_name( - self, name: str, config_type: DiscoveryConfigType, namespace: str = "" - ) -> Optional[DiscoveryConfigLibrary]: + def get_discovery_config_library_by_name(self, name: str, namespace: str = "") -> Optional[DiscoveryConfigLibrary]: """ - Looks for a discovery config library matching the given name, type, and namespace (case-sensitive, exact match). + Looks for a discovery config library matching the given name 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. + Library names are unique within a namespace. Returns it (with full YAML content) if found, otherwise `None`. """ - library_id = self._get_discovery_config_library_id_by_name(name, config_type, namespace) + library_id = self._get_discovery_config_library_id_by_name(name, namespace) if library_id is None: return None @@ -106,7 +96,6 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di 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: @@ -129,12 +118,12 @@ def update_discovery_config_library(self, library: DiscoveryConfigLibrary) -> Di 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. + Creates the library, or updates the existing one with the same name and namespace. Sets the library's `id` property. """ - library_id = self._get_discovery_config_library_id_by_name(library.name, library.config_type, library.namespace) + library_id = self._get_discovery_config_library_id_by_name(library.name, library.namespace) if library_id is not None: library.id = library_id return self.update_discovery_config_library(library) @@ -157,16 +146,15 @@ 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, config_type: DiscoveryConfigType, namespace: str = "", *, force: bool = False + self, name: str, namespace: str = "", *, force: bool = False ) -> None: """ - Deletes the discovery config library with the given name, type, and namespace. + Deletes the discovery config library with the given name and namespace. - Library names are unique per type within a namespace, - so a type is required to identify a single library. + Library names are unique within a namespace. No-op if no such library exists. """ - library_id = self._get_discovery_config_library_id_by_name(name, config_type, namespace) + library_id = self._get_discovery_config_library_id_by_name(name, 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 095cf18..9dbec49 100644 --- a/datamasque/client/models/discovery_config_library.py +++ b/datamasque/client/models/discovery_config_library.py @@ -3,7 +3,6 @@ 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) @@ -13,14 +12,13 @@ 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. + A library is untyped: the same library may be imported by both database and + file discovery configs, and its name is unique within a namespace. """ 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. diff --git a/pyproject.toml b/pyproject.toml index 2c40393..a94090e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "datamasque-python" -version = "1.1.7" +version = "1.2.0" description = "Official Python client for the DataMasque data-masking API." authors = [ { name = "DataMasque Ltd" }, diff --git a/tests/test_discovery_config_libraries.py b/tests/test_discovery_config_libraries.py index 5ad094b..d72cff3 100644 --- a/tests/test_discovery_config_libraries.py +++ b/tests/test_discovery_config_libraries.py @@ -5,13 +5,11 @@ 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 @@ -28,7 +26,6 @@ def sample_library_list_response() -> list[dict[str, Any]]: "id": LIBRARY_ID_1, "name": "my_library", "namespace": "org", - "config_type": "database", "is_valid": "valid", "validation_error": None, "usage_count": 0, @@ -39,7 +36,6 @@ def sample_library_list_response() -> list[dict[str, Any]]: "id": LIBRARY_ID_2, "name": "another_library", "namespace": "", - "config_type": "file", "is_valid": "invalid", "validation_error": "bad yaml", "usage_count": 2, @@ -56,7 +52,6 @@ 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, @@ -66,44 +61,19 @@ def sample_library_detail_response() -> dict[str, Any]: } -@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_library_is_untyped() -> None: + """A library carries no `config_type`; the same library serves both config types.""" + library = DiscoveryConfigLibrary(name="test_library", yaml="labels: []\n") + assert "config_type" not in library.model_dump(by_alias=True) def test_list_discovery_config_libraries( @@ -121,12 +91,10 @@ 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 assert libraries[1].validation_error == "bad yaml" @@ -155,7 +123,6 @@ 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 @@ -176,7 +143,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", DiscoveryConfigType.database, "org") + library = client.get_discovery_config_library_by_name("my_library", "org") assert library is not None assert library.name == "my_library" @@ -193,7 +160,6 @@ def test_get_discovery_config_library_by_name_matches_namespace_client_side( "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", @@ -202,7 +168,6 @@ def test_get_discovery_config_library_by_name_matches_namespace_client_side( "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", @@ -220,7 +185,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", DiscoveryConfigType.database, "org") + library = client.get_discovery_config_library_by_name("my_library", "org") assert library is not None assert library.id == DiscoveryConfigLibraryId(LIBRARY_ID_1) @@ -235,7 +200,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", DiscoveryConfigType.database) + library = client.get_discovery_config_library_by_name("my_library") assert library is None assert m.call_count == 1 @@ -248,50 +213,17 @@ 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", DiscoveryConfigType.database) + library = client.get_discovery_config_library_by_name("nonexistent") 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", @@ -305,7 +237,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", DiscoveryConfigType.database, "org") + client.get_discovery_config_library_by_name("my_library", "org") def test_create_discovery_config_library(client: DataMasqueClient, config_library: DiscoveryConfigLibrary) -> None: @@ -313,7 +245,6 @@ def test_create_discovery_config_library(client: DataMasqueClient, config_librar "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, @@ -338,7 +269,7 @@ 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 "config_type" not in request_body 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() @@ -351,7 +282,6 @@ def test_create_discovery_config_library_reports_validation_error( "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", @@ -379,7 +309,6 @@ def test_update_discovery_config_library(client: DataMasqueClient, config_librar "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, @@ -431,7 +360,6 @@ def test_create_or_update_discovery_config_library_create( "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, @@ -466,7 +394,6 @@ def test_create_or_update_discovery_config_library_update( "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", @@ -476,7 +403,6 @@ def test_create_or_update_discovery_config_library_update( "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, @@ -503,42 +429,6 @@ def test_create_or_update_discovery_config_library_update( 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( @@ -583,33 +473,13 @@ 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", DiscoveryConfigType.database, "org") + 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_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: @@ -619,7 +489,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", DiscoveryConfigType.database) + client.delete_discovery_config_library_by_name_if_exists("nonexistent") assert m.call_count == 1 assert m.request_history[0].method == "GET" diff --git a/uv.lock b/uv.lock index 11c8a93..c6ed95e 100644 --- a/uv.lock +++ b/uv.lock @@ -428,7 +428,7 @@ toml = [ [[package]] name = "datamasque-python" -version = "1.1.7" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" },