datamasque-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datamasque/client/__init__.py +204 -0
- datamasque/client/base.py +304 -0
- datamasque/client/connections.py +64 -0
- datamasque/client/discovery.py +286 -0
- datamasque/client/dmclient.py +49 -0
- datamasque/client/exceptions.py +75 -0
- datamasque/client/files.py +92 -0
- datamasque/client/ifm.py +301 -0
- datamasque/client/license.py +41 -0
- datamasque/client/models/__init__.py +0 -0
- datamasque/client/models/connection.py +429 -0
- datamasque/client/models/data_selection.py +62 -0
- datamasque/client/models/discovery.py +229 -0
- datamasque/client/models/dm_instance.py +39 -0
- datamasque/client/models/files.py +89 -0
- datamasque/client/models/ifm.py +177 -0
- datamasque/client/models/license.py +60 -0
- datamasque/client/models/pagination.py +29 -0
- datamasque/client/models/ruleset.py +45 -0
- datamasque/client/models/ruleset_library.py +22 -0
- datamasque/client/models/runs.py +165 -0
- datamasque/client/models/status.py +68 -0
- datamasque/client/models/user.py +69 -0
- datamasque/client/py.typed +0 -0
- datamasque/client/ruleset_libraries.py +164 -0
- datamasque/client/rulesets.py +57 -0
- datamasque/client/runs.py +189 -0
- datamasque/client/settings.py +76 -0
- datamasque/client/users.py +96 -0
- datamasque_python-1.0.0.dist-info/METADATA +113 -0
- datamasque_python-1.0.0.dist-info/RECORD +33 -0
- datamasque_python-1.0.0.dist-info/WHEEL +4 -0
- datamasque_python-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Typed request and response shapes for run-related API endpoints."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, NewType, Optional, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from datamasque.client.models.connection import ConnectionConfig, ConnectionId, unwrap_connection_id
|
|
10
|
+
from datamasque.client.models.ruleset import Ruleset, RulesetId, unwrap_ruleset_id
|
|
11
|
+
from datamasque.client.models.status import MaskingRunStatus
|
|
12
|
+
|
|
13
|
+
RunId = NewType("RunId", int)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MaskType(enum.Enum):
|
|
17
|
+
"""Type of a masking run."""
|
|
18
|
+
|
|
19
|
+
database = "database" # Also used for schema discovery.
|
|
20
|
+
file = "file"
|
|
21
|
+
file_data_discovery = "file_data_discovery"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MaskingRunOptions(BaseModel):
|
|
25
|
+
"""
|
|
26
|
+
Optional run-time overrides for `MaskingRunRequest.options`.
|
|
27
|
+
|
|
28
|
+
All fields optional; server applies defaults when omitted.
|
|
29
|
+
`run_secret`,
|
|
30
|
+
if supplied,
|
|
31
|
+
must be 16–256 characters and is used as the per-run encryption key;
|
|
32
|
+
the server auto-generates one when omitted.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
batch_size: Optional[int] = None
|
|
38
|
+
dry_run: Optional[bool] = None
|
|
39
|
+
continue_on_failure: Optional[bool] = None
|
|
40
|
+
max_rows: Optional[int] = None
|
|
41
|
+
diagnostic_logging: Optional[bool] = None
|
|
42
|
+
run_secret: Optional[str] = Field(default=None, min_length=16, max_length=256)
|
|
43
|
+
disable_instance_secret: Optional[bool] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MaskingRunRequest(BaseModel):
|
|
47
|
+
"""
|
|
48
|
+
Request body for `POST /api/runs/`.
|
|
49
|
+
|
|
50
|
+
`connection`, `destination_connection`, and `ruleset` accept either the server-assigned ID
|
|
51
|
+
or the corresponding object returned by an earlier client call (e.g. a `ConnectionConfig`
|
|
52
|
+
or `Ruleset`); the object's `id` is extracted at construction time.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
connection: Union[ConnectionId, ConnectionConfig]
|
|
58
|
+
ruleset: Union[RulesetId, Ruleset]
|
|
59
|
+
mask_type: MaskType = MaskType.database
|
|
60
|
+
destination_connection: Optional[Union[ConnectionId, ConnectionConfig]] = None
|
|
61
|
+
options: MaskingRunOptions = Field(default_factory=MaskingRunOptions)
|
|
62
|
+
name: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
@field_validator("connection", "destination_connection", mode="before")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _unwrap_connection(cls, value: Any) -> Any:
|
|
67
|
+
return unwrap_connection_id(value)
|
|
68
|
+
|
|
69
|
+
@field_validator("ruleset", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _unwrap_ruleset(cls, value: Any) -> Any:
|
|
72
|
+
return unwrap_ruleset_id(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RunConnectionRef(BaseModel):
|
|
76
|
+
"""A reference to a connection used in a run — just the ID and display name."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="allow")
|
|
79
|
+
|
|
80
|
+
id: Optional[ConnectionId] = None
|
|
81
|
+
name: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _collapse_flat_connection_fields(data: Any) -> Any:
|
|
85
|
+
"""
|
|
86
|
+
Collapse flat `*_connection` + `*_connection_name` pairs into nested `RunConnectionRef`s.
|
|
87
|
+
|
|
88
|
+
The admin server sends connections as two parallel fields
|
|
89
|
+
(`source_connection` holding the ID and `source_connection_name` holding the display name);
|
|
90
|
+
the client surfaces them as a single nested object.
|
|
91
|
+
Leaves the input alone if the fields are already in nested form
|
|
92
|
+
(i.e. the caller constructed the model directly).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
data = dict(data)
|
|
99
|
+
|
|
100
|
+
if "source_connection_name" in data and not isinstance(data.get("source_connection"), dict):
|
|
101
|
+
data["source_connection"] = {
|
|
102
|
+
"id": data.pop("source_connection", None),
|
|
103
|
+
"name": data.pop("source_connection_name"),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
dest_name = data.get("destination_connection_name")
|
|
107
|
+
if dest_name and not isinstance(data.get("destination_connection"), dict):
|
|
108
|
+
data["destination_connection"] = {
|
|
109
|
+
"id": data.pop("destination_connection", None),
|
|
110
|
+
"name": data.pop("destination_connection_name"),
|
|
111
|
+
}
|
|
112
|
+
elif "destination_connection_name" in data:
|
|
113
|
+
# Empty string or None — let the Optional default apply.
|
|
114
|
+
data.pop("destination_connection_name", None)
|
|
115
|
+
data.pop("destination_connection", None)
|
|
116
|
+
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RunInfo(BaseModel):
|
|
121
|
+
"""Full record for a masking run."""
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(extra="allow")
|
|
124
|
+
|
|
125
|
+
id: int
|
|
126
|
+
status: MaskingRunStatus
|
|
127
|
+
mask_type: MaskType
|
|
128
|
+
source_connection: RunConnectionRef
|
|
129
|
+
ruleset_name: str
|
|
130
|
+
name: Optional[str] = None
|
|
131
|
+
destination_connection: Optional[RunConnectionRef] = None
|
|
132
|
+
ruleset: Optional[RulesetId] = None
|
|
133
|
+
start_time: Optional[datetime] = None
|
|
134
|
+
end_time: Optional[datetime] = None
|
|
135
|
+
options: Optional[dict[str, Any]] = None
|
|
136
|
+
|
|
137
|
+
@model_validator(mode="before")
|
|
138
|
+
@classmethod
|
|
139
|
+
def _collapse_connection_fields(cls, data: Any) -> Any:
|
|
140
|
+
return _collapse_flat_connection_fields(data)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class UnfinishedRun(BaseModel):
|
|
144
|
+
"""Represents a masking run that is queued, running, validating, or cancelling."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="allow")
|
|
147
|
+
|
|
148
|
+
id: int
|
|
149
|
+
source_connection: RunConnectionRef
|
|
150
|
+
ruleset_name: str
|
|
151
|
+
status: MaskingRunStatus
|
|
152
|
+
destination_connection: Optional[RunConnectionRef] = None
|
|
153
|
+
|
|
154
|
+
@model_validator(mode="before")
|
|
155
|
+
@classmethod
|
|
156
|
+
def _collapse_connection_fields(cls, data: Any) -> Any:
|
|
157
|
+
return _collapse_flat_connection_fields(data)
|
|
158
|
+
|
|
159
|
+
def __str__(self) -> str:
|
|
160
|
+
if self.destination_connection is not None:
|
|
161
|
+
connection_part = f'"{self.source_connection.name}", "{self.destination_connection.name}"'
|
|
162
|
+
else:
|
|
163
|
+
connection_part = f'"{self.source_connection.name}"'
|
|
164
|
+
|
|
165
|
+
return f'{connection_part}: Run ID {self.id} in status `{self.status.value}`, ruleset "{self.ruleset_name}"'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ValidationStatus(enum.Enum):
|
|
5
|
+
"""Validation status of a ruleset or ruleset library."""
|
|
6
|
+
|
|
7
|
+
valid = "valid"
|
|
8
|
+
invalid = "invalid"
|
|
9
|
+
in_progress = "in_progress"
|
|
10
|
+
unknown = "unknown"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MaskingRunStatus(enum.Enum):
|
|
14
|
+
"""List of valid masking run statuses."""
|
|
15
|
+
|
|
16
|
+
finished = "finished"
|
|
17
|
+
finished_with_warnings = "finished_with_warnings"
|
|
18
|
+
queued = "queued"
|
|
19
|
+
running = "running"
|
|
20
|
+
failed = "failed"
|
|
21
|
+
validating = "validating"
|
|
22
|
+
cancelling = "cancelling"
|
|
23
|
+
cancelled = "cancelled"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def get_final_states(cls) -> set["MaskingRunStatus"]:
|
|
27
|
+
"""Returns the list of final statuses, i.e. the run is completed, successfully or otherwise."""
|
|
28
|
+
|
|
29
|
+
return {cls.finished, cls.finished_with_warnings, cls.cancelled, cls.failed}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def get_finished_states(cls) -> set["MaskingRunStatus"]:
|
|
33
|
+
"""Returns the list of statuses that indicate the run completed successfully."""
|
|
34
|
+
|
|
35
|
+
return {cls.finished, cls.finished_with_warnings}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_in_final_state(self) -> bool:
|
|
39
|
+
"""Returns True if this status is a final status."""
|
|
40
|
+
|
|
41
|
+
return self in self.get_final_states()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_finished(self) -> bool:
|
|
45
|
+
"""Returns True if this status is a finished status."""
|
|
46
|
+
|
|
47
|
+
return self in self.get_finished_states()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncRulesetGenerationTaskStatus(enum.Enum):
|
|
51
|
+
"""List of statuses of async ruleset generation tasks."""
|
|
52
|
+
|
|
53
|
+
finished = "finished"
|
|
54
|
+
failed = "failed"
|
|
55
|
+
running = "running"
|
|
56
|
+
queued = "queued"
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def get_final_states(cls) -> set["AsyncRulesetGenerationTaskStatus"]:
|
|
60
|
+
"""Returns the list of final statuses, i.e. the ruleset generation has completed, successfully or otherwise."""
|
|
61
|
+
|
|
62
|
+
return {cls.finished, cls.failed}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_in_final_state(self) -> bool:
|
|
66
|
+
"""Returns True if this status is a final status."""
|
|
67
|
+
|
|
68
|
+
return self in self.get_final_states()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import string
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import NewType, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
UserId = NewType("UserId", int)
|
|
9
|
+
|
|
10
|
+
GENERATED_PASSWORD_LENGTH = 16
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserRole(Enum):
|
|
14
|
+
"""
|
|
15
|
+
List of supported user roles.
|
|
16
|
+
|
|
17
|
+
`ruleset_library_manager` can be optionally included alongside `mask_builder`.
|
|
18
|
+
It is not valid as a standalone role.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
superuser = "admin"
|
|
22
|
+
mask_builder = "mask_builder"
|
|
23
|
+
ruleset_library_manager = "ruleset_library_managers"
|
|
24
|
+
mask_runner = "mask_runner"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class User(BaseModel):
|
|
28
|
+
"""Represents a DataMasque user account."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
31
|
+
|
|
32
|
+
username: str
|
|
33
|
+
email: str
|
|
34
|
+
roles: list[UserRole] = Field(alias="user_roles")
|
|
35
|
+
id: Optional[UserId] = None
|
|
36
|
+
password: Optional[str] = Field(default=None, exclude=True)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def generate_password() -> str:
|
|
40
|
+
"""
|
|
41
|
+
Generates a password suitable for DataMasque authentication.
|
|
42
|
+
|
|
43
|
+
The password consists of 16 characters
|
|
44
|
+
without the same character occurring three times in a row
|
|
45
|
+
and without any three consecutive characters forming an increasing or decreasing sequence.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def is_sequential(s: str) -> bool:
|
|
49
|
+
"""Check if the last three characters are in an increasing or decreasing sequence."""
|
|
50
|
+
|
|
51
|
+
if len(s) < 3:
|
|
52
|
+
return False
|
|
53
|
+
return (ord(s[-1]) == ord(s[-2]) + 1 == ord(s[-3]) + 2) or (ord(s[-1]) == ord(s[-2]) - 1 == ord(s[-3]) - 2)
|
|
54
|
+
|
|
55
|
+
chars = string.ascii_letters + string.digits
|
|
56
|
+
result = secrets.choice(chars)
|
|
57
|
+
|
|
58
|
+
while len(result) < GENERATED_PASSWORD_LENGTH:
|
|
59
|
+
next_char = secrets.choice(chars)
|
|
60
|
+
if len(result) >= 2 and next_char == result[-1] == result[-2]:
|
|
61
|
+
continue
|
|
62
|
+
if is_sequential(result + next_char):
|
|
63
|
+
continue
|
|
64
|
+
result += next_char
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return self.username
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Iterator, Optional
|
|
3
|
+
|
|
4
|
+
from datamasque.client.base import BaseClient
|
|
5
|
+
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException
|
|
6
|
+
from datamasque.client.models.pagination import Page
|
|
7
|
+
from datamasque.client.models.ruleset import Ruleset
|
|
8
|
+
from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RulesetLibraryClient(BaseClient):
|
|
14
|
+
"""Ruleset library CRUD API methods. Mixed into `DataMasqueClient`."""
|
|
15
|
+
|
|
16
|
+
def iter_ruleset_libraries(self) -> Iterator[RulesetLibrary]:
|
|
17
|
+
"""Lazily iterate all ruleset libraries via paginated endpoint."""
|
|
18
|
+
|
|
19
|
+
return self._iter_paginated("/api/ruleset-libraries/", model=RulesetLibrary)
|
|
20
|
+
|
|
21
|
+
def list_ruleset_libraries(self) -> list[RulesetLibrary]:
|
|
22
|
+
"""
|
|
23
|
+
Lists all ruleset libraries.
|
|
24
|
+
|
|
25
|
+
Note: The YAML content is not included in the list response for performance.
|
|
26
|
+
Use `get_ruleset_library` to retrieve the full library with YAML content.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
return list(self.iter_ruleset_libraries())
|
|
30
|
+
|
|
31
|
+
def get_ruleset_library(self, library_id: RulesetLibraryId) -> RulesetLibrary:
|
|
32
|
+
"""Retrieves a single ruleset library by ID, including its YAML content."""
|
|
33
|
+
|
|
34
|
+
response = self.make_request("GET", f"/api/ruleset-libraries/{library_id}/")
|
|
35
|
+
return RulesetLibrary.model_validate(response.json())
|
|
36
|
+
|
|
37
|
+
def get_ruleset_library_by_name(self, name: str, namespace: str = "") -> Optional[RulesetLibrary]:
|
|
38
|
+
"""
|
|
39
|
+
Looks for a ruleset library matching the given name and namespace (case-sensitive, exact match).
|
|
40
|
+
|
|
41
|
+
Returns it (with full YAML content) if found, otherwise None.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
response = self.make_request(
|
|
45
|
+
"GET",
|
|
46
|
+
"/api/ruleset-libraries/",
|
|
47
|
+
params={"name_exact": name, "namespace_exact": namespace, "limit": 1},
|
|
48
|
+
)
|
|
49
|
+
page = Page[RulesetLibrary].model_validate(response.json())
|
|
50
|
+
if not page.results:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
library_id = page.results[0].id
|
|
54
|
+
if library_id is None:
|
|
55
|
+
raise DataMasqueApiError(
|
|
56
|
+
"Server returned a ruleset library list entry without an `id`.",
|
|
57
|
+
response=response,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return self.get_ruleset_library(library_id)
|
|
61
|
+
|
|
62
|
+
def create_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
|
|
63
|
+
"""
|
|
64
|
+
Creates a new ruleset library on the server.
|
|
65
|
+
|
|
66
|
+
Sets the library's server-assigned fields (`id`, `is_valid`, `created`, `modified`) and returns the library.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
70
|
+
response = self.make_request("POST", "/api/ruleset-libraries/", data=data)
|
|
71
|
+
created_library = RulesetLibrary.model_validate(response.json())
|
|
72
|
+
library.id = created_library.id
|
|
73
|
+
library.is_valid = created_library.is_valid
|
|
74
|
+
library.created = created_library.created
|
|
75
|
+
library.modified = created_library.modified
|
|
76
|
+
logger.info('Creation of ruleset library "%s" successful', library.name)
|
|
77
|
+
return library
|
|
78
|
+
|
|
79
|
+
def update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
|
|
80
|
+
"""
|
|
81
|
+
Performs a full update of the ruleset library.
|
|
82
|
+
|
|
83
|
+
The library must have its `id` set (i.e., it must have been previously created or retrieved from the server).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
if library.id is None:
|
|
87
|
+
raise ValueError("Cannot update a library that has not been created yet (id is None)")
|
|
88
|
+
|
|
89
|
+
data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
90
|
+
response = self.make_request("PUT", f"/api/ruleset-libraries/{library.id}/", data=data)
|
|
91
|
+
updated_library = RulesetLibrary.model_validate(response.json())
|
|
92
|
+
library.is_valid = updated_library.is_valid
|
|
93
|
+
library.modified = updated_library.modified
|
|
94
|
+
logger.debug('Update of ruleset library "%s" successful', library.name)
|
|
95
|
+
return library
|
|
96
|
+
|
|
97
|
+
def create_or_update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary:
|
|
98
|
+
"""
|
|
99
|
+
Creates the library if it doesn't exist, or updates it if a library with the same name already exists.
|
|
100
|
+
|
|
101
|
+
Sets the library's `id` property.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
existing = self.get_ruleset_library_by_name(library.name, library.namespace)
|
|
105
|
+
if existing is not None:
|
|
106
|
+
library.id = existing.id
|
|
107
|
+
return self.update_ruleset_library(library)
|
|
108
|
+
|
|
109
|
+
return self.create_ruleset_library(library)
|
|
110
|
+
|
|
111
|
+
def delete_ruleset_library_by_id_if_exists(self, library_id: RulesetLibraryId, *, force: bool = False) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Deletes (archives) the ruleset library with the given ID.
|
|
114
|
+
|
|
115
|
+
No-op if the library does not exist.
|
|
116
|
+
|
|
117
|
+
If the library is imported by any rulesets,
|
|
118
|
+
the server will return 409 Conflict unless `force=True` is passed.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
params = {"force": "true"} if force else None
|
|
122
|
+
self._delete_if_exists(f"/api/ruleset-libraries/{library_id}/", params=params)
|
|
123
|
+
|
|
124
|
+
def delete_ruleset_library_by_name_if_exists(
|
|
125
|
+
self, library_name: str, namespace: str = "", *, force: bool = False
|
|
126
|
+
) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Deletes the ruleset library with the given name and namespace.
|
|
129
|
+
|
|
130
|
+
No-op if the library does not exist.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
all_libraries = self.list_ruleset_libraries()
|
|
134
|
+
matching = [lib for lib in all_libraries if lib.name == library_name and lib.namespace == namespace]
|
|
135
|
+
for lib in matching:
|
|
136
|
+
if lib.id is None:
|
|
137
|
+
raise DataMasqueException(f'Server returned a ruleset library named "{lib.name}" without an `id`.')
|
|
138
|
+
|
|
139
|
+
self.delete_ruleset_library_by_id_if_exists(lib.id, force=force)
|
|
140
|
+
|
|
141
|
+
def iter_rulesets_using_library(self, library_id: RulesetLibraryId) -> Iterator[Ruleset]:
|
|
142
|
+
"""Lazily iterate non-archived rulesets that import the given library."""
|
|
143
|
+
|
|
144
|
+
return self._iter_paginated(f"/api/ruleset-libraries/{library_id}/rulesets/", model=Ruleset)
|
|
145
|
+
|
|
146
|
+
def list_rulesets_using_library(self, library_id: RulesetLibraryId) -> list[Ruleset]:
|
|
147
|
+
"""
|
|
148
|
+
Lists non-archived rulesets that import the given library.
|
|
149
|
+
|
|
150
|
+
Note: The YAML content is not included in the response for performance.
|
|
151
|
+
Each returned Ruleset will have an empty string for `yaml`.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
return list(self.iter_rulesets_using_library(library_id))
|
|
155
|
+
|
|
156
|
+
def validate_ruleset_library(self, library_id: RulesetLibraryId) -> RulesetLibrary:
|
|
157
|
+
"""
|
|
158
|
+
Triggers re-validation of the ruleset library by performing a no-op update.
|
|
159
|
+
|
|
160
|
+
Returns the updated library with the new validation status.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
response = self.make_request("PATCH", f"/api/ruleset-libraries/{library_id}/", data={})
|
|
164
|
+
return RulesetLibrary.model_validate(response.json())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from datamasque.client.base import BaseClient
|
|
4
|
+
from datamasque.client.exceptions import DataMasqueException
|
|
5
|
+
from datamasque.client.models.ruleset import Ruleset, RulesetId
|
|
6
|
+
from datamasque.client.models.status import ValidationStatus
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RulesetClient(BaseClient):
|
|
12
|
+
"""Ruleset CRUD API methods. Mixed into `DataMasqueClient`."""
|
|
13
|
+
|
|
14
|
+
def list_rulesets(self) -> list[Ruleset]:
|
|
15
|
+
"""Returns all rulesets configured on the server."""
|
|
16
|
+
|
|
17
|
+
response = self.make_request("GET", "/api/v2/rulesets/")
|
|
18
|
+
return [Ruleset.model_validate(payload) for payload in response.json()]
|
|
19
|
+
|
|
20
|
+
def create_or_update_ruleset(self, ruleset: Ruleset) -> Ruleset:
|
|
21
|
+
"""
|
|
22
|
+
Creates or updates a ruleset.
|
|
23
|
+
|
|
24
|
+
Populates the given ruleset's `id` and `is_valid` fields from the server response,
|
|
25
|
+
and returns the same ruleset instance for convenience.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
data = ruleset.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
29
|
+
response = self.make_request("POST", "/api/rulesets/", data=data, params={"upsert": "true"})
|
|
30
|
+
response_data = response.json()
|
|
31
|
+
ruleset.id = RulesetId(response_data["id"])
|
|
32
|
+
is_valid = response_data.get("is_valid")
|
|
33
|
+
if is_valid is not None:
|
|
34
|
+
ruleset.is_valid = ValidationStatus(is_valid)
|
|
35
|
+
|
|
36
|
+
if response.status_code == 201:
|
|
37
|
+
logger.info('Creation of ruleset "%s" successful', ruleset.name)
|
|
38
|
+
elif response.status_code == 200:
|
|
39
|
+
logger.debug('Update of ruleset "%s" successful', ruleset.name)
|
|
40
|
+
|
|
41
|
+
return ruleset
|
|
42
|
+
|
|
43
|
+
def delete_ruleset_by_id_if_exists(self, ruleset_id: RulesetId) -> None:
|
|
44
|
+
"""Deletes the ruleset with the given ID. No-op if the ruleset does not exist."""
|
|
45
|
+
|
|
46
|
+
self._delete_if_exists(f"/api/rulesets/{ruleset_id}/")
|
|
47
|
+
|
|
48
|
+
def delete_ruleset_by_name_if_exists(self, ruleset_name: str) -> None:
|
|
49
|
+
"""Deletes the ruleset with the given name. No-op if the ruleset does not exist."""
|
|
50
|
+
|
|
51
|
+
all_rulesets = self.list_rulesets()
|
|
52
|
+
rulesets_matching_name = [ruleset for ruleset in all_rulesets if ruleset.name == ruleset_name]
|
|
53
|
+
for ruleset in rulesets_matching_name:
|
|
54
|
+
if ruleset.id is None:
|
|
55
|
+
raise DataMasqueException(f'Server returned a ruleset named "{ruleset.name}" without an `id`.')
|
|
56
|
+
|
|
57
|
+
self.delete_ruleset_by_id_if_exists(ruleset.id)
|