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.
@@ -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