diff --git a/HISTORY.rst b/HISTORY.rst index cf9720f..e9f6d6c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,18 @@ History ======= +1.1.8 (unreleased) +------------------ + +* Added ruleset-generation (RG) config management APIs + (``list_rg_configs``, ``create_rg_config``, ``get_default_rg_config_yaml``, and friends). +* Added versioned ruleset-generation methods that take a required ``rg_config`` + (``generate_ruleset_with_rg_config``, ``generate_file_ruleset_with_rg_config``, + ``start_async_ruleset_generation_with_rg_config``, + ``start_async_ruleset_generation_from_csv_with_rg_config``). + The existing generate methods keep using the prior API versions, + which apply the server's default RG config. + 1.1.7 (2026-07-14) ------------------ diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py index e2275e3..5a80df8 100644 --- a/datamasque/client/__init__.py +++ b/datamasque/client/__init__.py @@ -67,11 +67,13 @@ FileFilter, FileFilterMatchAgainst, FileRulesetGenerationRequest, + FileRulesetGenerationWithRGConfigRequest, ForeignKeyRef, InDataDiscoveryConfig, InDataDiscoveryRule, ReferencingForeignKey, RulesetGenerationRequest, + RulesetGenerationWithRGConfigRequest, SchemaDiscoveryColumn, SchemaDiscoveryFromConfigRequest, SchemaDiscoveryPage, @@ -105,6 +107,7 @@ RulesetPlanUpdateRequest, ) from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata +from datamasque.client.models.rg_config import RGConfig, RGConfigId from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId from datamasque.client.models.runs import ( @@ -171,6 +174,7 @@ "FileId", "FileOrContent", "FileRulesetGenerationRequest", + "FileRulesetGenerationWithRGConfigRequest", "ForeignKeyRef", "GitSnapshot", "HashColumnsTableConfig", @@ -196,9 +200,12 @@ "MountedShareConnectionConfig", "MssqlLinkedServerConnectionConfig", "OracleWalletFile", + "RGConfig", + "RGConfigId", "ReferencingForeignKey", "Ruleset", "RulesetGenerationRequest", + "RulesetGenerationWithRGConfigRequest", "RulesetId", "RulesetLibrary", "RulesetLibraryId", diff --git a/datamasque/client/discovery.py b/datamasque/client/discovery.py index b9fe598..c53b020 100644 --- a/datamasque/client/discovery.py +++ b/datamasque/client/discovery.py @@ -25,12 +25,15 @@ FileDataDiscoveryRequest, FileDiscoveryResult, FileRulesetGenerationRequest, + FileRulesetGenerationWithRGConfigRequest, RulesetGenerationRequest, + RulesetGenerationWithRGConfigRequest, SchemaDiscoveryFromConfigRequest, SchemaDiscoveryPage, SchemaDiscoveryRequest, SchemaDiscoveryResult, ) +from datamasque.client.models.rg_config import RGConfig, RGConfigId, unwrap_rg_config_id from datamasque.client.models.ruleset import Ruleset from datamasque.client.models.runs import RunId from datamasque.client.models.status import AsyncRulesetGenerationTaskStatus @@ -41,21 +44,12 @@ class DiscoveryClient(BaseClient): """Schema-discovery and ruleset-generation API methods. Mixed into `DataMasqueClient`.""" - def start_async_ruleset_generation(self, connection_id: ConnectionId, selected_data: SelectedData) -> None: - """ - Starts async ruleset generation using the most recent discovery results on the given connection. - - If the connection is a database connection, `selected_data` should be of type `SelectedColumns`. - If the connection is a file connection, `selected_data` should be of type `SelectedFileData`. - - Generation runs asynchronously on the server. - Poll `get_async_ruleset_generation_task_status` until it returns - `AsyncRulesetGenerationTaskStatus.finished`, - then call `get_generated_rulesets` to retrieve the resulting `Ruleset`. - """ + @staticmethod + def _selected_data_payload(selected_data: SelectedData) -> dict: + """Build the async-generation request-body fields for a column or file selection, validating its shape.""" if not selected_data: - raise ValueError("`selected_data` is a required argument to `start_async_ruleset_generation`.") + raise ValueError("`selected_data` is a required argument to async ruleset generation.") data: dict = {} if isinstance(selected_data, SelectedColumns): @@ -75,12 +69,54 @@ def start_async_ruleset_generation(self, connection_id: ConnectionId, selected_d data["selected_data"] = [s.model_dump() for s in selected_data.user_selections] else: raise TypeError( - f"The argument `selected_data` to `start_async_ruleset_generation` was of an invalid type, " + f"The `selected_data` argument to async ruleset generation was of an invalid type, " f"expected `SelectedColumns` or `SelectedFileData`, got {type(selected_data)}." ) + return data + + def start_async_ruleset_generation(self, connection_id: ConnectionId, selected_data: SelectedData) -> None: + """ + Starts async ruleset generation using the most recent discovery results on the given connection. + + Masks are assigned from the server's default RG config; + use `start_async_ruleset_generation_with_rg_config` to generate with a saved RG config. + + If the connection is a database connection, `selected_data` should be of type `SelectedColumns`. + If the connection is a file connection, `selected_data` should be of type `SelectedFileData`. + + Generation runs asynchronously on the server. + Poll `get_async_ruleset_generation_task_status` until it returns + `AsyncRulesetGenerationTaskStatus.finished`, + then call `get_generated_rulesets` to retrieve the resulting `Ruleset`. + """ + + data = self._selected_data_payload(selected_data) self.make_request(method="POST", path=f"/api/async-generate-ruleset/{connection_id}/", data=data) + def start_async_ruleset_generation_with_rg_config( + self, + connection_id: ConnectionId, + selected_data: SelectedData, + rg_config: Optional[Union[RGConfigId, RGConfig]], + ) -> None: + """ + Starts async ruleset generation with a selected RG config mapping discovered labels to masks. + + Like `start_async_ruleset_generation`, but posts to the v2 endpoint with a required `rg_config`: + a saved RG config (`RGConfigId` or `RGConfig`), or `None` for the server's default RG config. + + Generation runs asynchronously on the server. + Poll `get_async_ruleset_generation_task_status` until it returns + `AsyncRulesetGenerationTaskStatus.finished`, + then call `get_generated_rulesets` to retrieve the resulting `Ruleset`. + """ + + data = self._selected_data_payload(selected_data) + # The server requires `rg_config` to be present; an explicit null selects the default RG config. + data["rg_config"] = unwrap_rg_config_id(rg_config) + self.make_request(method="POST", path=f"/api/async-generate-ruleset/v2/{connection_id}/", data=data) + def start_async_ruleset_generation_from_csv( self, connection_id: ConnectionId, @@ -108,6 +144,52 @@ def start_async_ruleset_generation_from_csv( then call `get_generated_rulesets` to retrieve the resulting `Ruleset` objects. """ + self.make_request( + method="POST", + path=f"/api/async-generate-ruleset/{connection_id}/from-csv/", + data={"target_size_bytes": target_size_bytes} if target_size_bytes is not None else None, + files=self._csv_upload_files(csv_content), + ) + + def start_async_ruleset_generation_from_csv_with_rg_config( + self, + connection_id: ConnectionId, + csv_content: Union[str, bytes, TextIOBase, BufferedIOBase], + rg_config: Optional[Union[RGConfigId, RGConfig]], + target_size_bytes: Optional[int] = None, + ) -> None: + """ + Generate ruleset(s) from a schema discovery CSV report with a selected RG config. + + Like `start_async_ruleset_generation_from_csv`, but posts to the v2 endpoint with a required + `rg_config`: a saved RG config (`RGConfigId` or `RGConfig`), or `None` for the server's + default RG config. + + Generation runs asynchronously on the server. + Poll `get_async_ruleset_generation_task_status` until it returns + `AsyncRulesetGenerationTaskStatus.finished`, + then call `get_generated_rulesets` to retrieve the resulting `Ruleset` objects. + """ + + rg_config_id = unwrap_rg_config_id(rg_config) + # The upload is a multipart form, whose fields cannot carry a JSON null; + # the server reads an empty string as null for this nullable field, + # selecting the default RG config. + data: dict = {"rg_config": rg_config_id if rg_config_id is not None else ""} + if target_size_bytes is not None: + data["target_size_bytes"] = target_size_bytes + + self.make_request( + method="POST", + path=f"/api/async-generate-ruleset/v2/{connection_id}/from-csv/", + data=data, + files=self._csv_upload_files(csv_content), + ) + + @staticmethod + def _csv_upload_files(csv_content: Union[str, bytes, TextIOBase, BufferedIOBase]) -> list[UploadFile]: + """Normalise CSV-or-zip report content into the `csv_or_zip_file` multipart upload.""" + content: BufferedIOBase if isinstance(csv_content, str): content = BytesIO(csv_content.encode()) @@ -125,7 +207,7 @@ def start_async_ruleset_generation_from_csv( filename = "ruleset.zip" if is_zip else "ruleset.csv" content_type = "application/zip" if is_zip else "text/csv" - files = [ + return [ UploadFile( field_name="csv_or_zip_file", filename=filename, @@ -134,13 +216,6 @@ def start_async_ruleset_generation_from_csv( ), ] - self.make_request( - method="POST", - path=f"/api/async-generate-ruleset/{connection_id}/from-csv/", - data={"target_size_bytes": target_size_bytes} if target_size_bytes is not None else None, - files=files, - ) - def get_async_ruleset_generation_task_status(self, connection_id: ConnectionId) -> AsyncRulesetGenerationTaskStatus: """Queries the status of an async ruleset generation task.""" @@ -433,6 +508,9 @@ def generate_ruleset(self, generation_request: RulesetGenerationRequest) -> str: """ Generates database-masking ruleset YAML from the most recent discovery run on the given connection. + Masks are assigned from the server's default RG config; + use `generate_ruleset_with_rg_config` to generate with a saved RG config. + `generation_request` is a `RulesetGenerationRequest`. """ @@ -440,10 +518,29 @@ def generate_ruleset(self, generation_request: RulesetGenerationRequest) -> str: response = self.make_request("POST", "/api/generate-ruleset/v2/", data=data) return response.content.decode("utf-8") + def generate_ruleset_with_rg_config(self, generation_request: RulesetGenerationWithRGConfigRequest) -> str: + """ + Generates database-masking ruleset YAML with a selected RG config mapping discovered labels to masks. + + `generation_request` is a `RulesetGenerationWithRGConfigRequest`; + its required `rg_config` selects the saved RG config to use + (`None` for the server's default RG config). + """ + + data = generation_request.model_dump(exclude_none=True, mode="json") + # The server requires `rg_config` to be present; a null selects its default RG config, + # so send it explicitly rather than letting `exclude_none` drop a None. + data.setdefault("rg_config", None) + response = self.make_request("POST", "/api/generate-ruleset/v3/", data=data) + return response.content.decode("utf-8") + def generate_file_ruleset(self, generation_request: FileRulesetGenerationRequest) -> str: """ Generates file-masking ruleset YAML from the most recent file-data-discovery run on the given connection. + Masks are assigned from the server's default RG config; + use `generate_file_ruleset_with_rg_config` to generate with a saved RG config. + `generation_request` is a `FileRulesetGenerationRequest`. """ @@ -451,6 +548,22 @@ def generate_file_ruleset(self, generation_request: FileRulesetGenerationRequest response = self.make_request("POST", "/api/generate-file-ruleset/", data=data) return response.content.decode("utf-8") + def generate_file_ruleset_with_rg_config(self, generation_request: FileRulesetGenerationWithRGConfigRequest) -> str: + """ + Generates file-masking ruleset YAML with a selected RG config mapping discovered labels to masks. + + `generation_request` is a `FileRulesetGenerationWithRGConfigRequest`; + its required `rg_config` selects the saved RG config to use + (`None` for the server's default RG config). + """ + + data = generation_request.model_dump(exclude_none=True, mode="json") + # The server requires `rg_config` to be present; a null selects its default RG config, + # so send it explicitly rather than letting `exclude_none` drop a None. + data.setdefault("rg_config", None) + response = self.make_request("POST", "/api/generate-file-ruleset/v2/", data=data) + return response.content.decode("utf-8") + def get_file_data_discovery_report(self, run_id: RunId) -> list[FileDiscoveryResult]: """Returns the file-data-discovery results for the specified run.""" diff --git a/datamasque/client/dmclient.py b/datamasque/client/dmclient.py index 4b9ba3d..ed4730d 100644 --- a/datamasque/client/dmclient.py +++ b/datamasque/client/dmclient.py @@ -5,6 +5,7 @@ from datamasque.client.discovery_configs import DiscoveryConfigClient from datamasque.client.files import FileClient from datamasque.client.license import LicenseClient +from datamasque.client.rg_configs import RGConfigClient from datamasque.client.ruleset_libraries import RulesetLibraryClient from datamasque.client.rulesets import RulesetClient from datamasque.client.runs import RunClient @@ -24,6 +25,7 @@ class DataMasqueClient( DiscoveryClient, DiscoveryConfigClient, DiscoveryConfigLibraryClient, + RGConfigClient, UserClient, SettingsClient, ): diff --git a/datamasque/client/models/discovery.py b/datamasque/client/models/discovery.py index 23a5a88..60f415e 100644 --- a/datamasque/client/models/discovery.py +++ b/datamasque/client/models/discovery.py @@ -9,6 +9,7 @@ from datamasque.client.models.data_selection import HashColumnsTableConfig, Locator, UserSelection from datamasque.client.models.discovery_config import DiscoveryConfig, DiscoveryConfigId, unwrap_discovery_config_id from datamasque.client.models.pagination import Page +from datamasque.client.models.rg_config import RGConfig, RGConfigId, unwrap_rg_config_id from datamasque.client.models.runs import RunConnectionRef @@ -101,12 +102,13 @@ def _unwrap_discovery_config(cls, value: Any) -> Any: class RulesetGenerationRequest(BaseModel): """ - Request body for `POST /api/generate-ruleset/v2/`. + Request body for `POST /api/generate-ruleset/v2/` (generation with the default RG config). `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. `selected_columns` is the same nested `schema -> table -> [column, ...]` mapping used by `SelectedColumns.columns`, and `hash_columns` follows the `HashColumnsTableConfig` shape. + This request does not accept an `rg_config`. """ model_config = ConfigDict(extra="forbid") @@ -120,6 +122,45 @@ class RulesetGenerationRequest(BaseModel): def _unwrap_connection(cls, value: Any) -> Any: return unwrap_connection_id(value) + @model_validator(mode="before") + @classmethod + def _reject_rg_config(cls, data: Any) -> Any: + if isinstance(data, dict) and "rg_config" in data: + raise ValueError( + "`rg_config` is not accepted by the v2 ruleset-generation request; " + "use `generate_ruleset_with_rg_config` with a `RulesetGenerationWithRGConfigRequest` " + "to generate with a selected RG config." + ) + return data + + +class RulesetGenerationWithRGConfigRequest(BaseModel): + """ + Request body for `POST /api/generate-ruleset/v3/` (generation with a selected RG config). + + `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + `selected_columns` and `hash_columns` follow the same shapes as `RulesetGenerationRequest`. + `rg_config` is required: pass an `RGConfigId`, a full `RGConfig`, + or `None` to generate with the server's default RG config. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + selected_columns: dict[str, dict[str, list[str]]] + rg_config: Optional[Union[RGConfigId, RGConfig]] + hash_columns: Optional[dict[str, dict[str, HashColumnsTableConfig]]] = None + + @field_validator("connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + @field_validator("rg_config", mode="before") + @classmethod + def _unwrap_rg_config(cls, value: Any) -> Any: + return unwrap_rg_config_id(value) + class FileFilterMatchAgainst(Enum): """Which part of a file's path an `include`/`skip` filter is matched against.""" @@ -231,9 +272,10 @@ def _unwrap_discovery_config(cls, value: Any) -> Any: class FileRulesetGenerationRequest(BaseModel): """ - Request body for `POST /api/generate-file-ruleset/`. + Request body for `POST /api/generate-file-ruleset/` (generation with the default RG config). `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + This request does not accept an `rg_config`. """ model_config = ConfigDict(extra="forbid") @@ -246,6 +288,43 @@ class FileRulesetGenerationRequest(BaseModel): def _unwrap_connection(cls, value: Any) -> Any: return unwrap_connection_id(value) + @model_validator(mode="before") + @classmethod + def _reject_rg_config(cls, data: Any) -> Any: + if isinstance(data, dict) and "rg_config" in data: + raise ValueError( + "`rg_config` is not accepted by the unversioned file-ruleset-generation request; " + "use `generate_file_ruleset_with_rg_config` with a `FileRulesetGenerationWithRGConfigRequest` " + "to generate with a selected RG config." + ) + return data + + +class FileRulesetGenerationWithRGConfigRequest(BaseModel): + """ + Request body for `POST /api/generate-file-ruleset/v2/` (generation with a selected RG config). + + `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + `rg_config` is required: pass an `RGConfigId`, a full `RGConfig`, + or `None` to generate with the server's default RG config. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + selected_data: list[UserSelection] + rg_config: Optional[Union[RGConfigId, RGConfig]] + + @field_validator("connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + @field_validator("rg_config", mode="before") + @classmethod + def _unwrap_rg_config(cls, value: Any) -> Any: + return unwrap_rg_config_id(value) + class DiscoveryMatch(BaseModel): """A single match found by schema or file discovery.""" diff --git a/datamasque/client/models/rg_config.py b/datamasque/client/models/rg_config.py new file mode 100644 index 0000000..ddccc65 --- /dev/null +++ b/datamasque/client/models/rg_config.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Any, NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from datamasque.client.models.status import ValidationStatus + +RGConfigId = NewType("RGConfigId", str) + + +def unwrap_rg_config_id(value: Any) -> Any: + """ + Coerce an `RGConfig` to its `id`; pass other values through unchanged. + + Used by request-model validators and the versioned generate methods that accept + either an `RGConfigId` or a full `RGConfig` for user convenience. + Raises `ValueError` if the config has no `id` + (i.e. the caller hasn't yet created it on the server). + """ + + if isinstance(value, RGConfig): + if value.id is None: + raise ValueError("RG config has not been created yet (id is None)") + return value.id + + return value + + +class RGConfig(BaseModel): + """ + Represents a named, persisted YAML ruleset-generation (RG) configuration. + + An RG config maps discovered labels to masks; ruleset generation applies it to a + discovery run's results. Unlike discovery configs, RG configs are untyped — + the same config serves both database and file ruleset generation. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + yaml: Optional[str] = Field(default=None, alias="config_yaml") + + # Server-populated read-only fields, excluded from request bodies. + id: Optional[RGConfigId] = Field(default=None, exclude=True) + is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) + """Validation status; may be `in_progress` briefly after creating a large config.""" + validation_error: Optional[str] = Field(default=None, exclude=True) + """Human-readable validation error, or `None` when valid.""" + created: Optional[datetime] = Field(default=None, exclude=True) + modified: Optional[datetime] = Field(default=None, exclude=True) diff --git a/datamasque/client/rg_configs.py b/datamasque/client/rg_configs.py new file mode 100644 index 0000000..7d758ef --- /dev/null +++ b/datamasque/client/rg_configs.py @@ -0,0 +1,151 @@ +import logging +from typing import Iterator, Optional + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.pagination import Page +from datamasque.client.models.rg_config import RGConfig, RGConfigId + +logger = logging.getLogger(__name__) + + +class RGConfigClient(BaseClient): + """Ruleset-generation (RG) config CRUD API methods. Mixed into `DataMasqueClient`.""" + + def iter_rg_configs(self) -> Iterator[RGConfig]: + """Lazily iterate all RG configs via the paginated endpoint.""" + + return self._iter_paginated("/api/rg/configs/", model=RGConfig) + + def list_rg_configs(self) -> list[RGConfig]: + """ + Lists all RG configs. + + Note: the YAML content is not included in the list response for performance. + Use `get_rg_config` to retrieve the full config with its YAML body. + """ + + return list(self.iter_rg_configs()) + + def get_rg_config(self, config_id: RGConfigId) -> RGConfig: + """Retrieves a single RG config by ID.""" + + response = self.make_request("GET", f"/api/rg/configs/{config_id}/") + return RGConfig.model_validate(response.json()) + + def _get_rg_config_id_by_name(self, name: str) -> Optional[RGConfigId]: + """Return the id of the config matching the name via a single list request, or `None`.""" + + response = self.make_request( + "GET", + "/api/rg/configs/", + params={"name_exact": name, "limit": 1}, + ) + page = Page[RGConfig].model_validate(response.json()) + if not page.results: + return None + + config_id = page.results[0].id + if config_id is None: + raise DataMasqueApiError( + "Server returned an RG config list entry without an `id`.", + response=response, + ) + + return config_id + + def get_rg_config_by_name(self, name: str) -> Optional[RGConfig]: + """ + Looks for an RG config matching the given name (case-sensitive, exact match). + + RG configs are untyped and their names are unique, so the name alone identifies a single config. + Returns it if found, otherwise `None`. + """ + + config_id = self._get_rg_config_id_by_name(name) + if config_id is None: + return None + + return self.get_rg_config(config_id) + + def create_rg_config(self, config: RGConfig) -> RGConfig: + """ + Creates a new RG config on the server. + + Sets the config's server-assigned fields + (`id`, `is_valid`, `validation_error`, `created`, `modified`) and returns the config. + """ + + data = config.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("POST", "/api/rg/configs/", data=data) + created = RGConfig.model_validate(response.json()) + config.id = created.id + config.is_valid = created.is_valid + config.validation_error = created.validation_error + config.created = created.created + config.modified = created.modified + logger.info('Creation of RG config "%s" successful', config.name) + return config + + def update_rg_config(self, config: RGConfig) -> RGConfig: + """ + Performs a full update of the RG config. + + The config must have its `id` set + (i.e., it must have been previously created or retrieved from the server). + """ + + if config.id is None: + raise ValueError("Cannot update an RG config that has not been created yet (id is None)") + + data = config.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("PUT", f"/api/rg/configs/{config.id}/", data=data) + updated = RGConfig.model_validate(response.json()) + config.is_valid = updated.is_valid + config.validation_error = updated.validation_error + config.modified = updated.modified + logger.debug('Update of RG config "%s" successful', config.name) + return config + + def create_or_update_rg_config(self, config: RGConfig) -> RGConfig: + """ + Creates the config if it doesn't exist, or updates it if one with the same name already exists. + + Sets the config's `id` property. + """ + + existing_id = self._get_rg_config_id_by_name(config.name) + if existing_id is not None: + config.id = existing_id + return self.update_rg_config(config) + + return self.create_rg_config(config) + + def delete_rg_config_by_id_if_exists(self, config_id: RGConfigId) -> None: + """ + Deletes the RG config with the given ID. + + No-op if the config does not exist. + """ + + self._delete_if_exists(f"/api/rg/configs/{config_id}/") + + def delete_rg_config_by_name_if_exists(self, name: str) -> None: + """ + Deletes the RG config with the given name. + + No-op if no such config exists. + """ + + matching = [config for config in self.list_rg_configs() if config.name == name] + for config in matching: + if config.id is None: + raise DataMasqueException(f'Server returned an RG config named "{config.name}" without an `id`.') + + self.delete_rg_config_by_id_if_exists(config.id) + + def get_default_rg_config_yaml(self) -> str: + """Returns the server's built-in default RG configuration as a YAML string.""" + + response = self.make_request("GET", "/api/rg/configs/defaults/") + return response.content.decode("utf-8") diff --git a/datamasque/client/ruleset_libraries.py b/datamasque/client/ruleset_libraries.py index 2e792c7..1b999c2 100644 --- a/datamasque/client/ruleset_libraries.py +++ b/datamasque/client/ruleset_libraries.py @@ -119,8 +119,9 @@ def delete_ruleset_library_by_id_if_exists(self, library_id: RulesetLibraryId, * No-op if the library does not exist. - If the library is imported by any rulesets, + If the library is imported by any rulesets or RG configs, the server will return 409 Conflict unless `force=True` is passed. + Forcing the deletion marks those dependents' validation state as invalid. """ params = {"force": "true"} if force else None @@ -152,6 +153,8 @@ def list_rulesets_using_library(self, library_id: RulesetLibraryId) -> list[Rule """ Lists rulesets that import the given library. + Only rulesets are listed; RG configs that import the library are not included. + Note: The YAML content is not included in the response for performance. Each returned Ruleset will have an empty string for `yaml`. """ diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 6121430..4981ef3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -19,8 +19,12 @@ FileFilter, FileFilterMatchAgainst, FileRulesetGenerationRequest, + FileRulesetGenerationWithRGConfigRequest, InDataDiscoveryConfig, + RGConfig, + RGConfigId, RulesetGenerationRequest, + RulesetGenerationWithRGConfigRequest, RunId, SchemaDiscoveryFromConfigRequest, SchemaDiscoveryPage, @@ -41,6 +45,7 @@ from tests.helpers import parse_multipart_form DISCOVERY_CONFIG_ID = "aaaaaaaa-1111-2222-3333-444444444444" +RG_CONFIG_ID = "bbbbbbbb-1111-2222-3333-444444444444" def test_generate_ruleset(client): @@ -1355,3 +1360,259 @@ def test_get_discovery_run_config_snapshot_yaml_empty_archive_raises(client): ) with pytest.raises(DataMasqueException, match="contained no files"): client.get_discovery_run_config_snapshot_yaml(RunId(7)) + + +def test_generate_ruleset_with_rg_config(client): + """`generate_ruleset_with_rg_config` posts the rg_config id to the v3 endpoint.""" + req = RulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + rg_config=RGConfigId(RG_CONFIG_ID), + ) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-ruleset/v3/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_ruleset_with_rg_config(req) == 'version: "1.0"' + + assert m.last_request.json() == { + "connection": "conn-1", + "selected_columns": {"public": {"users": ["email"]}}, + "rg_config": RG_CONFIG_ID, + } + + +def test_generate_ruleset_with_rg_config_none_sends_null(client): + """With `rg_config=None` the client posts an explicit null so the server applies its default RG config.""" + req = RulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + rg_config=None, + ) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-ruleset/v3/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_ruleset_with_rg_config(req) == 'version: "1.0"' + + assert m.last_request.json()["rg_config"] is None + + +def test_ruleset_generation_with_rg_config_request_unwraps_rg_config_model(): + """Passing a full `RGConfig` object substitutes its `id` for the wire payload.""" + config = RGConfig(name="my_rgc", id=RGConfigId(RG_CONFIG_ID)) + req = RulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + rg_config=config, + ) + assert req.model_dump(exclude_none=True, mode="json")["rg_config"] == RG_CONFIG_ID + + +def test_ruleset_generation_with_rg_config_request_rejects_unsaved_rg_config(): + """An `RGConfig` without an `id` cannot be used yet — raises immediately.""" + config = RGConfig(name="my_rgc") + with pytest.raises(ValueError, match="id is None"): + RulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + rg_config=config, + ) + + +def test_ruleset_generation_with_rg_config_request_requires_rg_config(): + """`rg_config` must be set explicitly; omitting it is a validation error (it may be None, but not absent).""" + with pytest.raises(ValidationError, match="rg_config"): + RulesetGenerationWithRGConfigRequest(connection="conn-1", selected_columns={"public": {"users": ["email"]}}) + + +def test_ruleset_generation_request_rejects_rg_config(): + """The v2 request rejects `rg_config` with a pointer at the versioned method.""" + with pytest.raises(ValidationError, match="generate_ruleset_with_rg_config"): + RulesetGenerationRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + rg_config=RGConfigId(RG_CONFIG_ID), + ) + + +def test_generate_file_ruleset_with_rg_config(client): + """`generate_file_ruleset_with_rg_config` posts the rg_config id to the v2 endpoint.""" + req = FileRulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_data=[UserSelection(locators=[["a"]], files=["f1.csv"])], + rg_config=RGConfigId(RG_CONFIG_ID), + ) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-file-ruleset/v2/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_file_ruleset_with_rg_config(req) == 'version: "1.0"' + + assert m.last_request.json() == { + "connection": "conn-1", + "selected_data": [{"locators": [["a"]], "files": ["f1.csv"]}], + "rg_config": RG_CONFIG_ID, + } + + +def test_generate_file_ruleset_with_rg_config_none_sends_null(client): + """With `rg_config=None` the file trigger posts an explicit null so the server applies its default RG config.""" + req = FileRulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_data=[UserSelection(locators=[["a"]], files=["f1.csv"])], + rg_config=None, + ) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-file-ruleset/v2/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_file_ruleset_with_rg_config(req) == 'version: "1.0"' + + assert m.last_request.json()["rg_config"] is None + + +def test_file_ruleset_generation_with_rg_config_request_requires_rg_config(): + """`rg_config` must be set explicitly; omitting it is a validation error (it may be None, but not absent).""" + with pytest.raises(ValidationError, match="rg_config"): + FileRulesetGenerationWithRGConfigRequest( + connection="conn-1", + selected_data=[UserSelection(locators=[["a"]], files=["f1.csv"])], + ) + + +def test_file_ruleset_generation_request_rejects_rg_config(): + """The unversioned file request rejects `rg_config` with a pointer at the versioned method.""" + with pytest.raises(ValidationError, match="generate_file_ruleset_with_rg_config"): + FileRulesetGenerationRequest( + connection="conn-1", + selected_data=[UserSelection(locators=[["a"]], files=["f1.csv"])], + rg_config=RGConfigId(RG_CONFIG_ID), + ) + + +def test_start_async_ruleset_generation_with_rg_config_columns(client): + """The async v2 trigger carries the column selection plus the rg_config id.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns(columns={"public": {"users": ["col1", "col2"]}}) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/", status_code=201) + client.start_async_ruleset_generation_with_rg_config(connection_id, selected_columns, RGConfigId(RG_CONFIG_ID)) + + request_data = m.last_request.json() + assert request_data["selected_columns"] == {"public": {"users": ["col1", "col2"]}} + assert request_data["rg_config"] == RG_CONFIG_ID + + +def test_start_async_ruleset_generation_with_rg_config_file(client): + """The async v2 trigger carries the file selection plus the rg_config id.""" + connection_id = ConnectionId("1") + selected_file_data = SelectedFileData(user_selections=[{"locators": [["locator1"]], "files": ["file1"]}]) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/", status_code=201) + client.start_async_ruleset_generation_with_rg_config( + connection_id, selected_file_data, RGConfigId(RG_CONFIG_ID) + ) + + request_data = m.last_request.json() + assert request_data["selected_data"] == [{"locators": [["locator1"]], "files": ["file1"]}] + assert request_data["rg_config"] == RG_CONFIG_ID + + +def test_start_async_ruleset_generation_with_rg_config_none_sends_null(client): + """With `rg_config=None` the async v2 trigger posts an explicit null to select the default RG config.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns(columns={"public": {"users": ["col1"]}}) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/", status_code=201) + client.start_async_ruleset_generation_with_rg_config(connection_id, selected_columns, None) + + assert m.last_request.json()["rg_config"] is None + + +def test_start_async_ruleset_generation_with_rg_config_unwraps_rg_config_model(client): + """A full `RGConfig` object is unwrapped to its id in the request body.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns(columns={"public": {"users": ["col1"]}}) + config = RGConfig(name="my_rgc", id=RGConfigId(RG_CONFIG_ID)) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/", status_code=201) + client.start_async_ruleset_generation_with_rg_config(connection_id, selected_columns, config) + + assert m.last_request.json()["rg_config"] == RG_CONFIG_ID + + +def test_start_async_ruleset_generation_with_rg_config_no_selected_data(client): + """The versioned trigger applies the same selection validation as the unversioned one.""" + connection_id = ConnectionId("1") + + with pytest.raises(ValueError, match="`selected_data` is a required argument"): + client.start_async_ruleset_generation_with_rg_config(connection_id, None, RGConfigId(RG_CONFIG_ID)) + + +def test_start_async_ruleset_generation_from_csv_with_rg_config(client): + """The CSV v2 trigger uploads the report and carries the rg_config id as a form field.""" + connection_id = ConnectionId("1") + csv_content = "schema,table,column,selected\npublic,users,email,true" + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/from-csv/", + status_code=201, + ) + client.start_async_ruleset_generation_from_csv_with_rg_config( + connection_id, csv_content, RGConfigId(RG_CONFIG_ID) + ) + + form_data = parse_multipart_form(m.last_request) + assert form_data["rg_config"] == RG_CONFIG_ID + assert form_data["csv_or_zip_file"]["filename"] == "ruleset.csv" + assert form_data["csv_or_zip_file"]["content_type"] == "text/csv" + assert form_data["csv_or_zip_file"]["content"] == b"schema,table,column,selected\npublic,users,email,true" + + +def test_start_async_ruleset_generation_from_csv_with_rg_config_none_sends_empty_field(client): + """Multipart forms cannot carry a JSON null; an empty `rg_config` field selects the default RG config.""" + connection_id = ConnectionId("1") + csv_content = "schema,table,column,selected\npublic,users,email,true" + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/from-csv/", + status_code=201, + ) + client.start_async_ruleset_generation_from_csv_with_rg_config(connection_id, csv_content, None) + + form_data = parse_multipart_form(m.last_request) + assert form_data["rg_config"] == "" + + +def test_start_async_ruleset_generation_from_csv_with_rg_config_with_target_size(client): + """`target_size_bytes` is still supported on the versioned CSV trigger.""" + connection_id = ConnectionId("1") + csv_content = "schema,table,column,selected\npublic,users,email,true" + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/v2/{connection_id}/from-csv/", + status_code=201, + ) + client.start_async_ruleset_generation_from_csv_with_rg_config( + connection_id, csv_content, RGConfigId(RG_CONFIG_ID), target_size_bytes=1024000 + ) + + form_data = parse_multipart_form(m.last_request) + assert form_data["rg_config"] == RG_CONFIG_ID + assert form_data["target_size_bytes"] == "1024000" diff --git a/tests/test_rg_configs.py b/tests/test_rg_configs.py new file mode 100644 index 0000000..17ac728 --- /dev/null +++ b/tests/test_rg_configs.py @@ -0,0 +1,474 @@ +"""Tests for ruleset-generation (RG) config 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, DataMasqueException +from datamasque.client.models.rg_config import ( + RGConfig, + RGConfigId, + unwrap_rg_config_id, +) +from datamasque.client.models.status import ValidationStatus + +CONFIG_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" +CONFIG_ID_2 = "bbbbbbbb-1111-2222-3333-444444444444" + +CONFIG_YAML = "version: '1.0'\nlabels:\n full_name: person_full_name\n" + + +@pytest.fixture +def sample_config_list_response() -> dict[str, Any]: + return { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": CONFIG_ID_1, + "name": "my_config", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": CONFIG_ID_2, + "name": "another_config", + "archived": False, + "created": "2025-02-01T12:00:00Z", + "modified": "2025-02-02T12:00:00Z", + }, + ], + } + + +@pytest.fixture +def sample_config_detail_response() -> dict[str, Any]: + return { + "id": CONFIG_ID_1, + "name": "my_config", + "config_yaml": CONFIG_YAML, + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + + +@pytest.fixture +def rg_config() -> RGConfig: + return RGConfig(name="test_config", yaml=CONFIG_YAML) + + +def test_list_rg_configs(client: DataMasqueClient, sample_config_list_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/", + json=sample_config_list_response, + status_code=200, + ) + configs = client.list_rg_configs() + + assert len(configs) == 2 + assert configs[0].id == RGConfigId(CONFIG_ID_1) + assert configs[0].name == "my_config" + assert configs[0].yaml is None + assert configs[1].name == "another_config" + + +def test_list_rg_configs_pagination(client: DataMasqueClient) -> None: + page1 = { + "count": 3, + "next": "http://test-server/api/rg/configs/?limit=2&offset=2", + "previous": None, + "results": [ + { + "id": CONFIG_ID_1, + "name": "c1", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + { + "id": CONFIG_ID_2, + "name": "c2", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + page2 = { + "count": 3, + "next": None, + "previous": "http://test-server/api/rg/configs/?limit=2", + "results": [ + { + "id": "cccccccc-1111-2222-3333-444444444444", + "name": "c3", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/", + [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], + ) + configs = client.list_rg_configs() + + assert [c.name for c in configs] == ["c1", "c2", "c3"] + + +def test_list_rg_configs_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/rg/configs/", json=empty_response, status_code=200) + configs = client.list_rg_configs() + + assert configs == [] + + +def test_get_rg_config(client: DataMasqueClient, sample_config_detail_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", + json=sample_config_detail_response, + status_code=200, + ) + config = client.get_rg_config(RGConfigId(CONFIG_ID_1)) + + assert config.id == RGConfigId(CONFIG_ID_1) + assert config.name == "my_config" + assert config.yaml == CONFIG_YAML + + +def test_get_rg_config_by_name_found(client: DataMasqueClient, sample_config_detail_response: dict[str, Any]) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": CONFIG_ID_1, + "name": "my_config", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/", + json=list_response, + status_code=200, + ) + m.get( + f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", + json=sample_config_detail_response, + status_code=200, + ) + config = client.get_rg_config_by_name("my_config") + + assert config is not None + assert config.name == "my_config" + assert "name_exact=my_config" in m.request_history[0].url + + +def test_get_rg_config_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/rg/configs/", + json=empty_response, + status_code=200, + ) + config = client.get_rg_config_by_name("nonexistent") + + assert config is None + + +def test_get_rg_config_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: + list_response_without_id = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "name": "my_config", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/", + json=list_response_without_id, + status_code=200, + ) + with pytest.raises(DataMasqueApiError, match="without an `id`"): + client.get_rg_config_by_name("my_config") + + +def test_create_rg_config(client: DataMasqueClient, rg_config: RGConfig) -> None: + create_response = { + "id": CONFIG_ID_1, + "name": "test_config", + "config_yaml": CONFIG_YAML, + "is_valid": "in_progress", + "archived": False, + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/rg/configs/", + json=create_response, + status_code=201, + ) + result = client.create_rg_config(rg_config) + + assert result is rg_config + assert result.id == RGConfigId(CONFIG_ID_1) + assert result.is_valid is ValidationStatus.in_progress + 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_config" + assert request_body["config_yaml"] == CONFIG_YAML + # Server-managed read-only fields must not be echoed back in the request body. + assert "id" not in request_body + assert "is_valid" not in request_body + assert "created" not in request_body + assert "modified" not in request_body + + +def test_update_rg_config(client: DataMasqueClient, rg_config: RGConfig) -> None: + rg_config.id = RGConfigId(CONFIG_ID_1) + + update_response = { + "id": CONFIG_ID_1, + "name": "test_config", + "config_yaml": CONFIG_YAML, + "archived": False, + "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/rg/configs/{CONFIG_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.update_rg_config(rg_config) + + assert result is rg_config + assert result.modified == datetime.fromisoformat("2025-06-02T10:00:00+00:00") + + request_body = m.last_request.json() + assert request_body["name"] == "test_config" + assert request_body["config_yaml"] == CONFIG_YAML + # The id identifies the config in the URL, not the request body. + assert "id" not in request_body + + +def test_update_rg_config_no_id_raises(client: DataMasqueClient, rg_config: RGConfig) -> None: + with pytest.raises(ValueError, match="id is None"): + client.update_rg_config(rg_config) + + +def test_create_or_update_rg_config_create(client: DataMasqueClient, rg_config: RGConfig) -> None: + empty_list = {"count": 0, "next": None, "previous": None, "results": []} + create_response = { + "id": CONFIG_ID_1, + "name": "test_config", + "config_yaml": CONFIG_YAML, + "archived": False, + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/rg/configs/", json=empty_list, status_code=200) + m.post("http://test-server/api/rg/configs/", json=create_response, status_code=201) + result = client.create_or_update_rg_config(rg_config) + + assert result.id == RGConfigId(CONFIG_ID_1) + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "POST" + + +def test_create_or_update_rg_config_update(client: DataMasqueClient, rg_config: RGConfig) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": CONFIG_ID_1, + "name": "test_config", + "archived": False, + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + }, + ], + } + update_response = { + "id": CONFIG_ID_1, + "name": "test_config", + "config_yaml": CONFIG_YAML, + "archived": False, + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-02T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/rg/configs/", json=list_response, status_code=200) + m.put( + f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.create_or_update_rg_config(rg_config) + + assert result.id == RGConfigId(CONFIG_ID_1) + # The upsert resolves the id from the list response alone — no detail GET before the PUT. + assert [r.method for r in m.request_history] == ["GET", "PUT"] + + +def test_delete_rg_config_by_id(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete(f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", status_code=204) + client.delete_rg_config_by_id_if_exists(RGConfigId(CONFIG_ID_1)) + + assert m.call_count == 1 + + +def test_delete_rg_config_by_id_not_found(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete(f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", status_code=404) + client.delete_rg_config_by_id_if_exists(RGConfigId(CONFIG_ID_1)) + + +def test_delete_rg_config_by_name(client: DataMasqueClient, sample_config_list_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/rg/configs/", json=sample_config_list_response, status_code=200) + m.delete(f"http://test-server/api/rg/configs/{CONFIG_ID_1}/", status_code=204) + client.delete_rg_config_by_name_if_exists("my_config") + + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + + +def test_delete_rg_config_by_name_not_found( + client: DataMasqueClient, sample_config_list_response: dict[str, Any] +) -> None: + with requests_mock.Mocker() as m: + m.get("http://test-server/api/rg/configs/", json=sample_config_list_response, status_code=200) + client.delete_rg_config_by_name_if_exists("nonexistent") + + assert m.call_count == 1 + assert m.request_history[0].method == "GET" + + +def test_delete_rg_config_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: + list_response_without_id = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "name": "my_config", + "archived": False, + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/", + json=list_response_without_id, + status_code=200, + ) + with pytest.raises(DataMasqueException, match="without an `id`"): + client.delete_rg_config_by_name_if_exists("my_config") + + +def test_get_default_rg_config_yaml(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/rg/configs/defaults/", + text=CONFIG_YAML, + status_code=200, + headers={"Content-Type": "application/x-yaml"}, + ) + result = client.get_default_rg_config_yaml() + + assert result == CONFIG_YAML + + +def test_rg_config_parses_validation_fields() -> None: + """`is_valid` and `validation_error` round-trip from API responses.""" + config = RGConfig.model_validate( + { + "id": CONFIG_ID_1, + "name": "my_config", + "config_yaml": "labels: []", + "is_valid": "invalid", + "validation_error": "bad shape on line 3", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + ) + + assert config.is_valid is ValidationStatus.invalid + assert config.validation_error == "bad shape on line 3" + + +def test_rg_config_validation_fields_optional() -> None: + """Older / lighter API responses without validation fields still parse.""" + config = RGConfig.model_validate( + { + "id": CONFIG_ID_1, + "name": "my_config", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + ) + + assert config.is_valid is None + assert config.validation_error is None + + +def test_unwrap_rg_config_id_passes_through_strings() -> None: + assert unwrap_rg_config_id(CONFIG_ID_1) == CONFIG_ID_1 + assert unwrap_rg_config_id(None) is None + + +def test_unwrap_rg_config_id_extracts_id_from_model() -> None: + config = RGConfig(name="x", id=RGConfigId(CONFIG_ID_1)) + assert unwrap_rg_config_id(config) == CONFIG_ID_1 + + +def test_unwrap_rg_config_id_raises_without_id() -> None: + config = RGConfig(name="x") + with pytest.raises(ValueError, match="id is None"): + unwrap_rg_config_id(config)