datamasque-python 1.0.2__py3-none-any.whl → 1.0.4__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.
@@ -30,7 +30,7 @@ from datamasque.client.models.connection import (
30
30
  ConnectionId,
31
31
  DatabaseConnectionConfig,
32
32
  DatabaseType,
33
- DatabricksDeltaS3ConnectionConfig,
33
+ DatabricksConnectionConfig,
34
34
  DynamoConnectionConfig,
35
35
  FileConnectionConfig,
36
36
  MongoConnectionConfig,
@@ -129,7 +129,7 @@ __all__ = [
129
129
  "DataMasqueUserError",
130
130
  "DatabaseConnectionConfig",
131
131
  "DatabaseType",
132
- "DatabricksDeltaS3ConnectionConfig",
132
+ "DatabricksConnectionConfig",
133
133
  "DiscoveryMatch",
134
134
  "DynamoConnectionConfig",
135
135
  "FailedToStartError",
datamasque/client/base.py CHANGED
@@ -1,7 +1,10 @@
1
1
  import logging
2
+ import platform
3
+ import sys
2
4
  import warnings
3
5
  from contextlib import contextmanager
4
6
  from dataclasses import dataclass
7
+ from importlib.metadata import PackageNotFoundError, version
5
8
  from io import BufferedIOBase, BytesIO, TextIOBase
6
9
  from pathlib import Path
7
10
  from typing import Any, Callable, Iterator, Optional, Type, TypeVar, Union
@@ -24,6 +27,44 @@ logger = logging.getLogger(__name__)
24
27
  FileOrContent = Union[str, bytes, TextIOBase, BufferedIOBase, Path]
25
28
  _T = TypeVar("_T", bound=BaseModel)
26
29
 
30
+
31
+ def _build_user_agent() -> str:
32
+ """
33
+ Identify ourselves to the DataMasque server in access logs and audit trails.
34
+
35
+ Default `python-requests/x.y.z` is anonymous; this surfaces the SDK name +
36
+ version, Python interpreter, and OS so operators can correlate API traffic
37
+ with a specific SDK release (e.g. when triaging a bug report).
38
+ """
39
+
40
+ try:
41
+ sdk_version = version("datamasque-python")
42
+ except PackageNotFoundError:
43
+ # Source checkouts without installed metadata.
44
+ sdk_version = "dev"
45
+ py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
46
+ return f"datamasque-python/{sdk_version} (Python/{py}; {platform.system()}/{platform.release()})"
47
+
48
+
49
+ USER_AGENT = _build_user_agent()
50
+
51
+
52
+ def _build_session(verify_ssl: bool) -> requests.Session:
53
+ """
54
+ Build a configured `requests.Session` for one client's lifetime.
55
+
56
+ Centralises the `User-Agent` and `verify` defaults so every call site
57
+ inherits them automatically — keeping the per-call code free of
58
+ boilerplate and removing the risk of forgetting either flag on a new
59
+ endpoint.
60
+ """
61
+
62
+ session = requests.Session()
63
+ session.headers["User-Agent"] = USER_AGENT
64
+ session.verify = verify_ssl
65
+ return session
66
+
67
+
27
68
  # Substrings (case-insensitive) that mark a key whose value should be redacted
28
69
  # before logging on an error path, so that passwords, API tokens, and similar secrets don't
29
70
  # end up in user-visible logs when a request fails.
@@ -71,6 +112,15 @@ class BaseClient:
71
112
 
72
113
  Holds the connection config, cached auth token, and the core `make_request` dispatcher
73
114
  used by all per-feature mixins that compose `DataMasqueClient`.
115
+
116
+ Uses a single `requests.Session` for the lifetime of the client so that
117
+ per-host TCP / TLS connections are pooled across calls (paginated list
118
+ endpoints and tight polling loops benefit most). Session-wide defaults
119
+ (`User-Agent`, `verify`) are set once on construction; per-call headers
120
+ like `Authorization` are merged at request time.
121
+
122
+ `requests.Session` is not thread-safe; do not share a client between
123
+ threads. Construct one per worker.
74
124
  """
75
125
 
76
126
  token: str = ""
@@ -86,6 +136,7 @@ class BaseClient:
86
136
  self.password = connection_config.password
87
137
  self.verify_ssl = connection_config.verify_ssl
88
138
  self.token_source = connection_config.token_source
139
+ self._session = _build_session(self.verify_ssl)
89
140
 
90
141
  @contextmanager
91
142
  def _maybe_suppress_insecure_warning(self) -> Iterator[None]:
@@ -186,28 +237,32 @@ class BaseClient:
186
237
  url = urljoin(self.base_url, path)
187
238
 
188
239
  def send() -> Response:
189
- headers: Optional[dict] = {"Authorization": self.token} if requires_authorization else None
240
+ headers = {"Authorization": self.token} if requires_authorization else None
190
241
  try:
191
242
  with self._maybe_suppress_insecure_warning():
192
243
  if files:
193
244
  files_payload = {f.field_name: (f.filename, f.content, f.content_type or "") for f in files}
194
- return requests.request(
245
+ return self._session.request(
195
246
  method,
196
247
  url,
197
248
  data=data,
198
249
  params=params,
199
250
  headers=headers,
200
251
  files=files_payload,
201
- verify=self.verify_ssl,
202
252
  )
203
- return requests.request(
204
- method, url, json=data, params=params, headers=headers, verify=self.verify_ssl
205
- )
253
+ return self._session.request(method, url, json=data, params=params, headers=headers)
206
254
  except requests.RequestException as e:
207
255
  raise DataMasqueTransportError(f"Failed to reach DataMasque server at {url}: {e}") from e
208
256
 
209
257
  response = send()
210
- if response.status_code == 401:
258
+ if response.status_code == 401 and requires_authorization:
259
+ # Token-expiry recovery: re-auth and replay. Only meaningful when the
260
+ # caller actually sent a token; on `requires_authorization=False`
261
+ # calls a 401 means the server itself is rejecting anonymous access
262
+ # (e.g. admin-install on an already-configured instance), and
263
+ # re-authing with whatever creds the client happens to hold would
264
+ # both misdiagnose the failure and emit a misleading
265
+ # "credentials are incorrect" error to the user.
211
266
  logger.debug("Re-authenticating")
212
267
  self.authenticate()
213
268
  # Reset file pointers so the retry doesn't send empty files
datamasque/client/ifm.py CHANGED
@@ -17,7 +17,7 @@ import requests
17
17
  from pydantic import BaseModel
18
18
  from requests import Response
19
19
 
20
- from datamasque.client.base import suppress_insecure_warning_if_needed
20
+ from datamasque.client.base import _build_session, suppress_insecure_warning_if_needed
21
21
  from datamasque.client.exceptions import (
22
22
  DataMasqueApiError,
23
23
  DataMasqueNotReadyError,
@@ -82,6 +82,9 @@ class DataMasqueIfmClient:
82
82
  self.password = connection_config.password
83
83
  self.verify_ssl = connection_config.verify_ssl
84
84
  self.token_source = connection_config.token_source
85
+ # One session for both admin-server (JWT login/refresh) and IFM (data plane)
86
+ # traffic -- different hosts, but a single session handles per-host pooling.
87
+ self._session = _build_session(self.verify_ssl)
85
88
 
86
89
  def authenticate(self) -> None:
87
90
  """Obtain an access (and refresh) token from the admin server, or via `token_source`."""
@@ -95,10 +98,9 @@ class DataMasqueIfmClient:
95
98
  login_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/login/")
96
99
  try:
97
100
  with self._maybe_suppress_insecure_warning():
98
- response = requests.post(
101
+ response = self._session.post(
99
102
  login_url,
100
103
  json={"username": self.username, "password": self.password},
101
- verify=self.verify_ssl,
102
104
  )
103
105
  except requests.RequestException as e:
104
106
  raise DataMasqueTransportError(f"Failed to reach admin server at {login_url}: {e}") from e
@@ -122,10 +124,9 @@ class DataMasqueIfmClient:
122
124
  refresh_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/refresh/")
123
125
  try:
124
126
  with self._maybe_suppress_insecure_warning():
125
- response = requests.post(
127
+ response = self._session.post(
126
128
  refresh_url,
127
129
  json={"refresh": self.refresh_token},
128
- verify=self.verify_ssl,
129
130
  )
130
131
  except requests.RequestException as e:
131
132
  raise DataMasqueTransportError(f"Failed to reach admin server at {refresh_url}: {e}") from e
@@ -187,13 +188,12 @@ class DataMasqueIfmClient:
187
188
  def send() -> Response:
188
189
  try:
189
190
  with self._maybe_suppress_insecure_warning():
190
- return requests.request(
191
+ return self._session.request(
191
192
  method,
192
193
  url,
193
194
  json=json_body,
194
195
  params=params,
195
196
  headers={"Authorization": f"Bearer {self.access_token}"},
196
- verify=self.verify_ssl,
197
197
  )
198
198
  except requests.RequestException as e:
199
199
  raise DataMasqueTransportError(f"Failed to reach IFM server at {url}: {e}") from e
@@ -45,6 +45,8 @@ class DatabaseType(Enum):
45
45
  snowflake = "snowflake"
46
46
  mongodb = "mongodb"
47
47
  databricks_lakebase = "databricks_lakebase"
48
+ databricks = "databricks"
49
+ informix = "informix"
48
50
 
49
51
 
50
52
  class SnowflakeStageLocation(str, Enum):
@@ -280,6 +282,8 @@ class DatabaseConnectionConfig(ConnectionConfig):
280
282
  raise ValueError("For Snowflake, use the SnowflakeConnectionConfig class instead")
281
283
  if self.database_type is DatabaseType.mongodb:
282
284
  raise ValueError("For MongoDB, use the MongoConnectionConfig class instead")
285
+ if self.database_type is DatabaseType.databricks:
286
+ raise ValueError("For Databricks SQL Warehouse, use the DatabricksConnectionConfig class instead")
283
287
  return self
284
288
 
285
289
  mask_type: Literal["database"] = "database"
@@ -391,19 +395,36 @@ class MountedShareConnectionConfig(FileConnectionConfig):
391
395
  type: Literal["mounted_share_connection"] = "mounted_share_connection"
392
396
 
393
397
 
394
- class DatabricksDeltaS3ConnectionConfig(FileConnectionConfig):
395
- """Connection configuration for Databricks Delta tables stored in S3."""
398
+ class DatabricksConnectionConfig(ConnectionConfig):
399
+ """Connection configuration for a Databricks SQL Warehouse."""
396
400
 
397
- type: Literal["databricks_delta_s3_connection"] = "databricks_delta_s3_connection"
398
- bucket: str = ""
399
- iam_role_arn: Optional[str] = None
401
+ server_hostname: str
402
+ http_path: str
403
+ access_token: Optional[str] = None
404
+ catalog: str
405
+ db_schema: Optional[str] = Field(default=None, alias="schema")
406
+ is_read_only: bool = False
407
+ version: str = "1.0"
408
+
409
+ mask_type: Literal["database"] = "database"
410
+ db_type: Literal["databricks"] = "databricks"
411
+
412
+ @property
413
+ def database_type(self) -> DatabaseType:
414
+ return DatabaseType.databricks
415
+
416
+ @model_validator(mode="before")
417
+ @classmethod
418
+ def _strip_encrypted_token(cls, data: dict) -> dict:
419
+ if isinstance(data, dict):
420
+ data.pop("access_token_encrypted", None)
421
+ return data
400
422
 
401
423
 
402
424
  FILE_TYPE_MAP: dict[str, type[FileConnectionConfig]] = {
403
425
  "s3_connection": S3ConnectionConfig,
404
426
  "azure_blob_connection": AzureConnectionConfig,
405
427
  "mounted_share_connection": MountedShareConnectionConfig,
406
- "databricks_delta_s3_connection": DatabricksDeltaS3ConnectionConfig,
407
428
  }
408
429
 
409
430
  DB_TYPE_MAP: dict[str, type[ConnectionConfig]] = {
@@ -411,6 +432,7 @@ DB_TYPE_MAP: dict[str, type[ConnectionConfig]] = {
411
432
  DatabaseType.mongodb.value: MongoConnectionConfig,
412
433
  DatabaseType.snowflake.value: SnowflakeConnectionConfig,
413
434
  DatabaseType.mssql_linked.value: MssqlLinkedServerConnectionConfig,
435
+ DatabaseType.databricks.value: DatabricksConnectionConfig,
414
436
  # others use the default `DatabaseConnectionConfig`
415
437
  }
416
438
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamasque-python
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Official Python client for the DataMasque data-masking API.
5
5
  Project-URL: Homepage, https://datamasque.com/
6
6
  Project-URL: Documentation, https://datamasque-python.readthedocs.io/
@@ -1,11 +1,11 @@
1
- datamasque/client/__init__.py,sha256=kEWW_L7t0J2gsBC9zwLUN3p8I2p26RqGHXo02aI3WRI,5486
2
- datamasque/client/base.py,sha256=he-ObABs_rpXV_KSMmrtJ73D-9lb0giAK029lN5qB3Y,11817
1
+ datamasque/client/__init__.py,sha256=rH7hQyA_nFVkv8eV_C1Ds_40v9xzCK__uXIJtWMLfuQ,5472
2
+ datamasque/client/base.py,sha256=vP1LuYbqq6U8NAEJWnwNrdt_Fa2wjQTJXHf0gSFO79c,14115
3
3
  datamasque/client/connections.py,sha256=EFinx8fJRme0mTxuWY3d29UnmUFbsQhMaUQT0Ma2PK4,2885
4
4
  datamasque/client/discovery.py,sha256=uA8h6vRqsxSAzSAV9bebJwU47XINlaVy7V1nTYDiaCM,12634
5
5
  datamasque/client/dmclient.py,sha256=OPYMzc57gUHPs6iL_J2DYp06MfOaabpTlISmnNCpqS4,1553
6
6
  datamasque/client/exceptions.py,sha256=F9FYCxP-ERXkVD1L3yh_rWqcW3IsPL-c-Ic4qMYoGnw,2542
7
7
  datamasque/client/files.py,sha256=5Gzel4aLby8T7ncOIV6wtgVbFEk0eaEy7wxohh9u0zE,3468
8
- datamasque/client/ifm.py,sha256=K4n9q-8f2wsmxnKlE-jkn9zl40iC9RGAw2tRB0mAtyM,11737
8
+ datamasque/client/ifm.py,sha256=uIMxpLIPvDiDO1m4bxezixNIUFIFY0MXWRMQNvTbpTA,11858
9
9
  datamasque/client/license.py,sha256=pluYaSU168OC6_laB9bM9H3Vuuxs8wa9gujCJvtoJCk,1392
10
10
  datamasque/client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  datamasque/client/ruleset_libraries.py,sha256=tyN--cndzG0gwFnHY9fDtObTLzGsK0wrV5lxxjRP72g,6868
@@ -14,7 +14,7 @@ datamasque/client/runs.py,sha256=ZPSkkuyqMiwy7dLbWZ1PAEKaesKLeNRX7xEJGfzieVg,742
14
14
  datamasque/client/settings.py,sha256=Ui8AyR2XdoW8MZ9FIrGn2jm8DLzUGWIL8i2DOTvu7hc,2898
15
15
  datamasque/client/users.py,sha256=VCUo2CJyOw4-aO_3mp_w_0BcSoa6-h1HcReMcAMyumw,3701
16
16
  datamasque/client/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- datamasque/client/models/connection.py,sha256=mQtWLAKcExbOkMymKmadprdWLbXl0V9aFZ4Zla2v0jc,15091
17
+ datamasque/client/models/connection.py,sha256=DQkn1kVTu3ODNNBjd-nR1JZhck5kalWhHJBuDWGNzU0,15778
18
18
  datamasque/client/models/data_selection.py,sha256=406yyUZ5NmLBSql2lYM1gsTWY5GWmnutmF-DjznAoLc,1904
19
19
  datamasque/client/models/discovery.py,sha256=BawKusPuhyt0gRRnWKYf-ZKF7V54GxkhgYIrEQ6XkOs,7283
20
20
  datamasque/client/models/dm_instance.py,sha256=yjjpHZJTFhJp3lAinTEffgKrxnadrJys1EuhO77wQcA,1561
@@ -27,7 +27,7 @@ datamasque/client/models/ruleset_library.py,sha256=gIqb6yn4-f9i2OydOLk1cd0zId9nG
27
27
  datamasque/client/models/runs.py,sha256=oVYo9jp9s5LjWts0LKsYpX3HUBmrVwuuzDqFtQmTjvo,5673
28
28
  datamasque/client/models/status.py,sha256=rjH6YSwAHoOUaUCADwvMhuKd0ygT0c2w2Ek5SV8PWD8,1984
29
29
  datamasque/client/models/user.py,sha256=UGAUzgJkf78m24_zFXXoA99zdut48BXkX_ivV8yq1Vc,2043
30
- datamasque_python-1.0.2.dist-info/METADATA,sha256=-D8IeXp8l_ZzTAp2gEjNBppqtyaXrz-j1SWOqTUJscI,4187
31
- datamasque_python-1.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
32
- datamasque_python-1.0.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
33
- datamasque_python-1.0.2.dist-info/RECORD,,
30
+ datamasque_python-1.0.4.dist-info/METADATA,sha256=eIH7wSpr6f9J6twjwzCbrKHZ7Sn3jFs-DLXo_F5Lzus,4187
31
+ datamasque_python-1.0.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
32
+ datamasque_python-1.0.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
33
+ datamasque_python-1.0.4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any