Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------

Expand Down
7 changes: 7 additions & 0 deletions datamasque/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@
FileFilter,
FileFilterMatchAgainst,
FileRulesetGenerationRequest,
FileRulesetGenerationWithRGConfigRequest,
ForeignKeyRef,
InDataDiscoveryConfig,
InDataDiscoveryRule,
ReferencingForeignKey,
RulesetGenerationRequest,
RulesetGenerationWithRGConfigRequest,
SchemaDiscoveryColumn,
SchemaDiscoveryFromConfigRequest,
SchemaDiscoveryPage,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -171,6 +174,7 @@
"FileId",
"FileOrContent",
"FileRulesetGenerationRequest",
"FileRulesetGenerationWithRGConfigRequest",
"ForeignKeyRef",
"GitSnapshot",
"HashColumnsTableConfig",
Expand All @@ -196,9 +200,12 @@
"MountedShareConnectionConfig",
"MssqlLinkedServerConnectionConfig",
"OracleWalletFile",
"RGConfig",
"RGConfigId",
"ReferencingForeignKey",
"Ruleset",
"RulesetGenerationRequest",
"RulesetGenerationWithRGConfigRequest",
"RulesetId",
"RulesetLibrary",
"RulesetLibraryId",
Expand Down
157 changes: 135 additions & 22 deletions datamasque/client/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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())
Expand All @@ -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,
Expand All @@ -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."""

Expand Down Expand Up @@ -433,24 +508,62 @@ 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`.
"""

data = generation_request.model_dump(exclude_none=True, mode="json")
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`.
"""

data = generation_request.model_dump(exclude_none=True, mode="json")
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."""

Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/dmclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@ class DataMasqueClient(
DiscoveryClient,
DiscoveryConfigClient,
DiscoveryConfigLibraryClient,
RGConfigClient,
UserClient,
SettingsClient,
):
Expand Down
Loading
Loading