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
datamasque/client/ifm.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for the DataMasque IFM (in-flight masking) HTTP API.
|
|
3
|
+
|
|
4
|
+
`DataMasqueIfmClient` mirrors the public IFM endpoints in a typed Python interface.
|
|
5
|
+
Authentication is JWT-based:
|
|
6
|
+
the access token is obtained from the admin server's `/api/auth/jwt/login/` endpoint
|
|
7
|
+
and refreshed via `/api/auth/jwt/refresh/` on a 401.
|
|
8
|
+
Users may also supply a `token_source` callable in the connection config to bypass admin-server login entirely.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Callable, Iterator, Optional, Type, TypeVar, Union
|
|
14
|
+
from urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
from requests import Response
|
|
19
|
+
|
|
20
|
+
from datamasque.client.base import suppress_insecure_warning_if_needed
|
|
21
|
+
from datamasque.client.exceptions import (
|
|
22
|
+
DataMasqueApiError,
|
|
23
|
+
DataMasqueNotReadyError,
|
|
24
|
+
DataMasqueTransportError,
|
|
25
|
+
IfmAuthError,
|
|
26
|
+
)
|
|
27
|
+
from datamasque.client.models.ifm import (
|
|
28
|
+
DataMasqueIfmInstanceConfig,
|
|
29
|
+
IfmMaskRequest,
|
|
30
|
+
IfmMaskResult,
|
|
31
|
+
IfmTokenInfo,
|
|
32
|
+
RulesetPlan,
|
|
33
|
+
RulesetPlanCreateRequest,
|
|
34
|
+
RulesetPlanPartialUpdateRequest,
|
|
35
|
+
RulesetPlanUpdateRequest,
|
|
36
|
+
)
|
|
37
|
+
from datamasque.client.models.pagination import IfmPage
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_IfmT = TypeVar("_IfmT", bound=BaseModel)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DataMasqueIfmClient:
|
|
45
|
+
"""
|
|
46
|
+
Client for a DataMasque IFM service.
|
|
47
|
+
|
|
48
|
+
Example usage:
|
|
49
|
+
|
|
50
|
+
.. code-block:: python
|
|
51
|
+
|
|
52
|
+
from datamasque.client import DataMasqueIfmClient, DataMasqueIfmInstanceConfig
|
|
53
|
+
|
|
54
|
+
config = DataMasqueIfmInstanceConfig(
|
|
55
|
+
admin_server_base_url="https://datamasque.example.com",
|
|
56
|
+
ifm_base_url="https://datamasque.example.com/ifm",
|
|
57
|
+
username="ifm_user",
|
|
58
|
+
password="ifm_password",
|
|
59
|
+
)
|
|
60
|
+
client = DataMasqueIfmClient(config)
|
|
61
|
+
|
|
62
|
+
for plan in client.list_ruleset_plans():
|
|
63
|
+
print(plan.name)
|
|
64
|
+
|
|
65
|
+
Authentication happens transparently on the first request,
|
|
66
|
+
with automatic token refresh on expiry.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
access_token: str = ""
|
|
70
|
+
refresh_token: str = ""
|
|
71
|
+
admin_server_base_url: str
|
|
72
|
+
ifm_base_url: str
|
|
73
|
+
username: str
|
|
74
|
+
password: Optional[str]
|
|
75
|
+
verify_ssl: bool
|
|
76
|
+
token_source: Optional[Callable[[], str]]
|
|
77
|
+
|
|
78
|
+
def __init__(self, connection_config: DataMasqueIfmInstanceConfig) -> None:
|
|
79
|
+
self.admin_server_base_url = connection_config.admin_server_base_url
|
|
80
|
+
self.ifm_base_url = connection_config.ifm_base_url
|
|
81
|
+
self.username = connection_config.username
|
|
82
|
+
self.password = connection_config.password
|
|
83
|
+
self.verify_ssl = connection_config.verify_ssl
|
|
84
|
+
self.token_source = connection_config.token_source
|
|
85
|
+
|
|
86
|
+
def authenticate(self) -> None:
|
|
87
|
+
"""Obtain an access (and refresh) token from the admin server, or via `token_source`."""
|
|
88
|
+
|
|
89
|
+
if self.token_source is not None:
|
|
90
|
+
self.access_token = self.token_source()
|
|
91
|
+
self.refresh_token = ""
|
|
92
|
+
logger.debug("IFM login success via token_source")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
login_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/login/")
|
|
96
|
+
try:
|
|
97
|
+
with self._maybe_suppress_insecure_warning():
|
|
98
|
+
response = requests.post(
|
|
99
|
+
login_url,
|
|
100
|
+
json={"username": self.username, "password": self.password},
|
|
101
|
+
verify=self.verify_ssl,
|
|
102
|
+
)
|
|
103
|
+
except requests.RequestException as e:
|
|
104
|
+
raise DataMasqueTransportError(f"Failed to reach admin server at {login_url}: {e}") from e
|
|
105
|
+
|
|
106
|
+
if response.status_code != 200:
|
|
107
|
+
logger.error("IFM JWT login failed: status %s", response.status_code)
|
|
108
|
+
raise IfmAuthError(f"Unable to obtain IFM JWT from admin server (status {response.status_code}).")
|
|
109
|
+
|
|
110
|
+
body = response.json()
|
|
111
|
+
self.access_token = body["access_token"]
|
|
112
|
+
self.refresh_token = body.get("refresh_token", "")
|
|
113
|
+
logger.debug("IFM JWT login success")
|
|
114
|
+
|
|
115
|
+
def _refresh_or_reauth(self) -> None:
|
|
116
|
+
"""Refresh the access token using the cached refresh token, or fall back to a full re-login."""
|
|
117
|
+
|
|
118
|
+
if self.token_source is not None or not self.refresh_token:
|
|
119
|
+
self.authenticate()
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
refresh_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/refresh/")
|
|
123
|
+
try:
|
|
124
|
+
with self._maybe_suppress_insecure_warning():
|
|
125
|
+
response = requests.post(
|
|
126
|
+
refresh_url,
|
|
127
|
+
json={"refresh": self.refresh_token},
|
|
128
|
+
verify=self.verify_ssl,
|
|
129
|
+
)
|
|
130
|
+
except requests.RequestException as e:
|
|
131
|
+
raise DataMasqueTransportError(f"Failed to reach admin server at {refresh_url}: {e}") from e
|
|
132
|
+
|
|
133
|
+
if response.status_code == 200:
|
|
134
|
+
self.access_token = response.json()["access_token"]
|
|
135
|
+
logger.debug("IFM JWT refresh success")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Refresh failed (probably expired) — fall back to a full login.
|
|
139
|
+
logger.debug("IFM JWT refresh failed (status %s); re-authenticating", response.status_code)
|
|
140
|
+
self.authenticate()
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def _maybe_suppress_insecure_warning(self) -> Iterator[None]:
|
|
144
|
+
with suppress_insecure_warning_if_needed(self.verify_ssl):
|
|
145
|
+
yield
|
|
146
|
+
|
|
147
|
+
def _iter_ifm_paginated(
|
|
148
|
+
self,
|
|
149
|
+
path: str,
|
|
150
|
+
model: Type[_IfmT],
|
|
151
|
+
*,
|
|
152
|
+
page_size: int = 100,
|
|
153
|
+
) -> Iterator[_IfmT]:
|
|
154
|
+
"""Iterate every `T` across all pages of an IFM list endpoint."""
|
|
155
|
+
|
|
156
|
+
offset = 0
|
|
157
|
+
while True:
|
|
158
|
+
response = self._make_request("GET", path, params={"limit": page_size, "offset": offset})
|
|
159
|
+
page = IfmPage[model].model_validate(response.json()) # type: ignore[valid-type]
|
|
160
|
+
yield from page.items
|
|
161
|
+
offset += len(page.items)
|
|
162
|
+
if not page.items or offset >= page.total:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
def _make_request(
|
|
166
|
+
self,
|
|
167
|
+
method: str,
|
|
168
|
+
path: str,
|
|
169
|
+
*,
|
|
170
|
+
json_body: Optional[Union[dict, list]] = None,
|
|
171
|
+
params: Optional[dict] = None,
|
|
172
|
+
require_status_check: bool = True,
|
|
173
|
+
) -> Response:
|
|
174
|
+
"""
|
|
175
|
+
Send an authenticated HTTP request to the IFM service.
|
|
176
|
+
|
|
177
|
+
Adds `Authorization: Bearer <jwt>`,
|
|
178
|
+
triggers a refresh-and-retry on a 401,
|
|
179
|
+
and raises `DataMasqueApiError` on a non-2xx final response when `require_status_check` is true.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
if not self.access_token:
|
|
183
|
+
self.authenticate()
|
|
184
|
+
|
|
185
|
+
url = urljoin(self.ifm_base_url.rstrip("/") + "/", path.lstrip("/"))
|
|
186
|
+
|
|
187
|
+
def send() -> Response:
|
|
188
|
+
try:
|
|
189
|
+
with self._maybe_suppress_insecure_warning():
|
|
190
|
+
return requests.request(
|
|
191
|
+
method,
|
|
192
|
+
url,
|
|
193
|
+
json=json_body,
|
|
194
|
+
params=params,
|
|
195
|
+
headers={"Authorization": f"Bearer {self.access_token}"},
|
|
196
|
+
verify=self.verify_ssl,
|
|
197
|
+
)
|
|
198
|
+
except requests.RequestException as e:
|
|
199
|
+
raise DataMasqueTransportError(f"Failed to reach IFM server at {url}: {e}") from e
|
|
200
|
+
|
|
201
|
+
response = send()
|
|
202
|
+
if response.status_code == 401:
|
|
203
|
+
logger.debug("IFM 401 — refreshing token and retrying")
|
|
204
|
+
self._refresh_or_reauth()
|
|
205
|
+
response = send()
|
|
206
|
+
|
|
207
|
+
if require_status_check and not response.ok:
|
|
208
|
+
if response.status_code == 502:
|
|
209
|
+
raise DataMasqueNotReadyError
|
|
210
|
+
|
|
211
|
+
raise DataMasqueApiError(
|
|
212
|
+
f"IFM API request to {response.request.url} failed with status {response.status_code}",
|
|
213
|
+
response=response,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return response
|
|
217
|
+
|
|
218
|
+
def verify_token(self) -> IfmTokenInfo:
|
|
219
|
+
"""`GET /verify-token/` — returns the list of scopes granted to the current JWT."""
|
|
220
|
+
|
|
221
|
+
return IfmTokenInfo.model_validate(self._make_request("GET", "verify-token/").json())
|
|
222
|
+
|
|
223
|
+
def iter_ruleset_plans(self) -> Iterator[RulesetPlan]:
|
|
224
|
+
"""Lazily iterate all ruleset plans via the paginated IFM endpoint."""
|
|
225
|
+
|
|
226
|
+
return self._iter_ifm_paginated("ruleset-plans/", model=RulesetPlan)
|
|
227
|
+
|
|
228
|
+
def list_ruleset_plans(self) -> list[RulesetPlan]:
|
|
229
|
+
"""`GET /ruleset-plans/` — list every ruleset plan visible to the current JWT."""
|
|
230
|
+
|
|
231
|
+
return list(self.iter_ruleset_plans())
|
|
232
|
+
|
|
233
|
+
def get_ruleset_plan(self, plan_name: str) -> RulesetPlan:
|
|
234
|
+
"""`GET /ruleset-plans/{plan_name}/` — fetch one plan including its ruleset YAML."""
|
|
235
|
+
|
|
236
|
+
return RulesetPlan.model_validate(self._make_request("GET", f"ruleset-plans/{plan_name}/").json())
|
|
237
|
+
|
|
238
|
+
def create_ruleset_plan(self, plan: RulesetPlanCreateRequest) -> RulesetPlan:
|
|
239
|
+
"""`POST /ruleset-plans/` — create a new plan; returns the persisted view including its URL."""
|
|
240
|
+
|
|
241
|
+
data = plan.model_dump(exclude_none=True, mode="json")
|
|
242
|
+
return RulesetPlan.model_validate(self._make_request("POST", "ruleset-plans/", json_body=data).json())
|
|
243
|
+
|
|
244
|
+
def update_ruleset_plan(self, plan_name: str, plan: RulesetPlanUpdateRequest) -> RulesetPlan:
|
|
245
|
+
"""`PUT /ruleset-plans/{plan_name}/` — full replace of an existing plan."""
|
|
246
|
+
|
|
247
|
+
data = plan.model_dump(exclude_none=True, mode="json")
|
|
248
|
+
return RulesetPlan.model_validate(
|
|
249
|
+
self._make_request("PUT", f"ruleset-plans/{plan_name}/", json_body=data).json()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def patch_ruleset_plan(self, plan_name: str, plan: RulesetPlanPartialUpdateRequest) -> RulesetPlan:
|
|
253
|
+
"""`PATCH /ruleset-plans/{plan_name}/` — partial update; only fields set on `plan` are sent."""
|
|
254
|
+
|
|
255
|
+
data = plan.model_dump(exclude_none=True, mode="json")
|
|
256
|
+
return RulesetPlan.model_validate(
|
|
257
|
+
self._make_request("PATCH", f"ruleset-plans/{plan_name}/", json_body=data).json()
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def delete_ruleset_plan(self, plan_name: str) -> None:
|
|
261
|
+
"""`DELETE /ruleset-plans/{plan_name}/` — no-op on the client side; raises on non-2xx server response."""
|
|
262
|
+
|
|
263
|
+
self._make_request("DELETE", f"ruleset-plans/{plan_name}/")
|
|
264
|
+
|
|
265
|
+
def mask(self, plan_name: str, request: IfmMaskRequest) -> IfmMaskResult:
|
|
266
|
+
"""
|
|
267
|
+
`POST /ruleset-plans/{plan_name}/mask/` — execute the named ruleset plan against `request.data`.
|
|
268
|
+
|
|
269
|
+
Returns an `IfmMaskResult` with `success=True` when the server returns 2xx
|
|
270
|
+
(`data` carries the masked records),
|
|
271
|
+
or `success=False` when the server returns a soft failure
|
|
272
|
+
(HTTP 400 with the full mask-result shape — `data` omitted, `logs` populated).
|
|
273
|
+
Network, auth, and other hard errors still raise
|
|
274
|
+
`DataMasqueApiError` / `IfmAuthError` / `DataMasqueNotReadyError`.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
data = request.model_dump(exclude_none=True, mode="json")
|
|
278
|
+
response = self._make_request(
|
|
279
|
+
"POST",
|
|
280
|
+
f"ruleset-plans/{plan_name}/mask/",
|
|
281
|
+
json_body=data,
|
|
282
|
+
require_status_check=False,
|
|
283
|
+
)
|
|
284
|
+
body = response.json() if response.content else {}
|
|
285
|
+
|
|
286
|
+
if response.ok:
|
|
287
|
+
return IfmMaskResult.model_validate(body | {"success": True})
|
|
288
|
+
|
|
289
|
+
# The server returns soft failures as HTTP 400 with the full IfmMaskResult body
|
|
290
|
+
# (`ruleset_plan` populated, `data` omitted, `logs` carries the detail).
|
|
291
|
+
# Any other 4xx/5xx is a hard error and still raises.
|
|
292
|
+
if response.status_code == 400 and isinstance(body, dict) and "ruleset_plan" in body:
|
|
293
|
+
return IfmMaskResult.model_validate(body | {"success": False})
|
|
294
|
+
|
|
295
|
+
if response.status_code == 502:
|
|
296
|
+
raise DataMasqueNotReadyError
|
|
297
|
+
|
|
298
|
+
raise DataMasqueApiError(
|
|
299
|
+
f"IFM API request to {response.request.url} failed with status {response.status_code}",
|
|
300
|
+
response=response,
|
|
301
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from datamasque.client.base import BaseClient, FileOrContent, UploadFile, read_file_or_content
|
|
4
|
+
from datamasque.client.models.license import LicenseInfo
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LicenseClient(BaseClient):
|
|
10
|
+
"""License management API methods. Mixed into `DataMasqueClient`."""
|
|
11
|
+
|
|
12
|
+
def upload_license_file(self, license_file: FileOrContent) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Uploads a DataMasque license.
|
|
15
|
+
|
|
16
|
+
Specify the path to a license (.dmlicense) filename,
|
|
17
|
+
or pass a `StringIO` or `BytesIO` containing the license content.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
license_file_name, content = read_file_or_content(license_file, "license.lic")
|
|
21
|
+
content.seek(0)
|
|
22
|
+
|
|
23
|
+
self.make_request(
|
|
24
|
+
method="POST",
|
|
25
|
+
path="/api/license-upload/",
|
|
26
|
+
files=[
|
|
27
|
+
UploadFile(
|
|
28
|
+
field_name="license_file",
|
|
29
|
+
filename=license_file_name,
|
|
30
|
+
content=content,
|
|
31
|
+
content_type="application/octet-stream",
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
logger.info("License upload successful.")
|
|
36
|
+
|
|
37
|
+
def get_current_license_info(self) -> LicenseInfo:
|
|
38
|
+
"""Returns information about the license currently installed on the server."""
|
|
39
|
+
|
|
40
|
+
response = self.make_request("GET", "/api/license/")
|
|
41
|
+
return LicenseInfo.model_validate(response.json())
|
|
File without changes
|