datamasque-python 1.0.2__tar.gz → 1.0.4__tar.gz

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.
Files changed (73) hide show
  1. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/HISTORY.rst +14 -0
  2. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/PKG-INFO +1 -1
  3. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/__init__.py +2 -2
  4. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/base.py +62 -7
  5. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/ifm.py +7 -7
  6. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/connection.py +28 -6
  7. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/pyproject.toml +1 -1
  8. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/setup.cfg +1 -1
  9. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_base.py +72 -18
  10. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_connections.py +46 -42
  11. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_ifm.py +14 -0
  12. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/uv.lock +1 -1
  13. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.editorconfig +0 -0
  14. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.github/workflows/ci.yml +0 -0
  15. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.github/workflows/release-testpypi.yml +0 -0
  16. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.github/workflows/release.yml +0 -0
  17. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.gitignore +0 -0
  18. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/.readthedocs.yaml +0 -0
  19. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/CONTRIBUTING.rst +0 -0
  20. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/LICENSE +0 -0
  21. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/MANIFEST.in +0 -0
  22. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/Makefile +0 -0
  23. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/NOTICE +0 -0
  24. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/README.rst +0 -0
  25. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/connections.py +0 -0
  26. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/discovery.py +0 -0
  27. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/dmclient.py +0 -0
  28. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/exceptions.py +0 -0
  29. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/files.py +0 -0
  30. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/license.py +0 -0
  31. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/__init__.py +0 -0
  32. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/data_selection.py +0 -0
  33. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/discovery.py +0 -0
  34. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/dm_instance.py +0 -0
  35. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/files.py +0 -0
  36. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/ifm.py +0 -0
  37. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/license.py +0 -0
  38. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/pagination.py +0 -0
  39. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/ruleset.py +0 -0
  40. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/ruleset_library.py +0 -0
  41. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/runs.py +0 -0
  42. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/status.py +0 -0
  43. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/models/user.py +0 -0
  44. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/py.typed +0 -0
  45. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/ruleset_libraries.py +0 -0
  46. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/rulesets.py +0 -0
  47. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/runs.py +0 -0
  48. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/settings.py +0 -0
  49. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/datamasque/client/users.py +0 -0
  50. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/Makefile +0 -0
  51. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/client.models.rst +0 -0
  52. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/client.rst +0 -0
  53. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/conf.py +0 -0
  54. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/contributing.rst +0 -0
  55. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/history.rst +0 -0
  56. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/index.rst +0 -0
  57. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/installation.rst +0 -0
  58. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/make.bat +0 -0
  59. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/modules.rst +0 -0
  60. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/readme.rst +0 -0
  61. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/docs/usage.rst +0 -0
  62. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/__init__.py +0 -0
  63. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/conftest.py +0 -0
  64. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/helpers.py +0 -0
  65. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_discovery.py +0 -0
  66. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_files.py +0 -0
  67. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_license.py +0 -0
  68. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_pagination.py +0 -0
  69. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_ruleset_library.py +0 -0
  70. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_rulesets.py +0 -0
  71. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_runs.py +0 -0
  72. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_settings.py +0 -0
  73. {datamasque_python-1.0.2 → datamasque_python-1.0.4}/tests/test_users.py +0 -0
@@ -2,6 +2,20 @@
2
2
  History
3
3
  =======
4
4
 
5
+ 1.0.4 (2026-06-09)
6
+ ------------------
7
+
8
+ * Added ``informix`` to ``DatabaseType`` enum.
9
+ * Pool HTTP connections via a per-client ``requests.Session`` so TCP/TLS connections are reused across calls. Note: a client is not thread-safe; construct one per worker.
10
+ * Send a descriptive ``User-Agent`` identifying the SDK name, version, Python interpreter, and OS.
11
+ * Only re-authenticate and replay on a ``401`` for requests that actually sent a token (gate the retry on ``requires_authorization``).
12
+
13
+ 1.0.3 (2026-05-27)
14
+ ------------------
15
+
16
+ * Added ``databricks`` to ``DatabaseType`` enum.
17
+ * Removed ``DatabricksDeltaS3ConnectionConfig``.
18
+
5
19
  1.0.2 (2026-05-14)
6
20
  ------------------
7
21
 
@@ -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/
@@ -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",
@@ -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
@@ -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
  [project]
2
2
  name = "datamasque-python"
3
- version = "1.0.2"
3
+ version = "1.0.4"
4
4
  description = "Official Python client for the DataMasque data-masking API."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.0.2
2
+ current_version = 1.0.4
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -10,6 +10,7 @@ import requests_mock
10
10
  from urllib3.exceptions import InsecureRequestWarning
11
11
 
12
12
  from datamasque.client import DataMasqueClient, RunId
13
+ from datamasque.client.base import USER_AGENT
13
14
  from datamasque.client.exceptions import (
14
15
  DataMasqueApiError,
15
16
  DataMasqueNotReadyError,
@@ -74,8 +75,12 @@ def test_healthcheck_transport_failure(client):
74
75
 
75
76
 
76
77
  @pytest.mark.parametrize("verify_ssl", [True, False])
77
- def test_make_request_verify_ssl_true_by_default(config, verify_ssl):
78
- """Verifies SSL setting is passed through to the `requests` call."""
78
+ def test_session_verify_reflects_config(config, verify_ssl):
79
+ """
80
+ `verify_ssl` is applied to the client's `requests.Session` once at construction.
81
+
82
+ Every outgoing call then inherits it without per-call boilerplate.
83
+ """
79
84
  config_with_ssl = DataMasqueInstanceConfig(
80
85
  base_url=config.base_url,
81
86
  username=config.username,
@@ -84,24 +89,14 @@ def test_make_request_verify_ssl_true_by_default(config, verify_ssl):
84
89
  )
85
90
  client = DataMasqueClient(config_with_ssl)
86
91
 
87
- with patch(
88
- "datamasque.client.base.requests.request",
89
- return_value=make_ok_response(),
90
- ) as mock_request:
91
- client.make_request("GET", "/api/test/")
92
-
93
- _, kwargs = mock_request.call_args
94
- assert kwargs["verify"] is verify_ssl
92
+ assert client._session.verify is verify_ssl
95
93
 
96
94
 
97
95
  def test_make_request_verify_ssl_true_does_not_touch_global_warning_filter(client):
98
96
  """With `verify_ssl=True`, the client should not modify `warnings.filters`."""
99
97
  filters_before = list(warnings.filters)
100
98
 
101
- with patch(
102
- "datamasque.client.base.requests.request",
103
- return_value=make_ok_response(),
104
- ):
99
+ with patch.object(client._session, "request", return_value=make_ok_response()):
105
100
  client.make_request("GET", "/api/test/")
106
101
 
107
102
  assert warnings.filters == filters_before
@@ -125,10 +120,7 @@ def test_make_request_verify_ssl_false_suppresses_warning_locally(config):
125
120
 
126
121
  with warnings.catch_warnings(record=True) as captured:
127
122
  warnings.simplefilter("always") # ensure we'd otherwise see the warning
128
- with patch(
129
- "datamasque.client.base.requests.request",
130
- side_effect=raise_insecure_warning_then_respond,
131
- ):
123
+ with patch.object(client._session, "request", side_effect=raise_insecure_warning_then_respond):
132
124
  client.make_request("GET", "/api/test/")
133
125
 
134
126
  # The warning raised inside the request call was suppressed by the client.
@@ -289,6 +281,68 @@ def test_token_source_called_again_on_401_retry():
289
281
  assert client.token == "Token t2"
290
282
 
291
283
 
284
+ def test_user_agent_identifies_the_sdk(client):
285
+ """
286
+ Every outgoing request must carry an SDK-identifying User-Agent header.
287
+
288
+ This lets operators attribute API traffic to a specific SDK release rather
289
+ than the generic `python-requests/x.y.z` default.
290
+ """
291
+ assert USER_AGENT.startswith("datamasque-python/")
292
+
293
+ with requests_mock.Mocker() as m:
294
+ m.get("http://test-server/api/healthcheck/", json={})
295
+ client.healthcheck()
296
+ assert m.request_history[0].headers["User-Agent"] == USER_AGENT
297
+
298
+
299
+ def test_user_agent_sent_on_authenticated_requests(client):
300
+ """
301
+ The User-Agent must be present on authenticated calls, alongside the auth token.
302
+
303
+ It is not limited to anonymous requests.
304
+ """
305
+ client.token = "Token test-token"
306
+
307
+ with requests_mock.Mocker() as m:
308
+ m.get("http://test-server/api/runs/", json={"results": [], "next": None})
309
+ client.make_request("GET", "/api/runs/")
310
+ headers = m.request_history[0].headers
311
+ assert headers["User-Agent"] == USER_AGENT
312
+ assert headers["Authorization"] == "Token test-token"
313
+
314
+
315
+ def test_401_does_not_retry_when_requires_authorization_is_false(client):
316
+ """
317
+ A 401 on an anonymous request must surface as-is, not trigger a re-auth retry.
318
+
319
+ `/api/users/admin-install/` returns 401 once any user exists -- the endpoint
320
+ is gated on "no user has been created yet" and DRF treats it as a normal
321
+ auth-required endpoint thereafter. Re-authing on that 401 would both
322
+ misdiagnose the failure ("login credentials are correct") and waste a
323
+ round-trip on a call the caller said doesn't need auth.
324
+ """
325
+ with requests_mock.Mocker() as m:
326
+ m.post(
327
+ "http://test-server/api/users/admin-install/",
328
+ status_code=401,
329
+ json={"detail": "Authentication credentials were not provided."},
330
+ )
331
+
332
+ with pytest.raises(DataMasqueApiError) as excinfo:
333
+ client.make_request(
334
+ "POST",
335
+ "/api/users/admin-install/",
336
+ data={"email": "x@y", "username": "x", "password": "p", "re_password": "p", "allowed_hosts": []},
337
+ requires_authorization=False,
338
+ )
339
+
340
+ assert excinfo.value.response.status_code == 401
341
+ # Exactly one request: no re-auth roundtrip to /api/auth/token/login/ and no replay.
342
+ assert m.call_count == 1
343
+ assert m.request_history[0].path == "/api/users/admin-install/"
344
+
345
+
292
346
  def test_token_source_callable_exception_propagates():
293
347
  """Errors from `token_source` are surfaced to the caller, not swallowed."""
294
348
 
@@ -9,7 +9,7 @@ from datamasque.client.models.connection import (
9
9
  ConnectionId,
10
10
  DatabaseConnectionConfig,
11
11
  DatabaseType,
12
- DatabricksDeltaS3ConnectionConfig,
12
+ DatabricksConnectionConfig,
13
13
  DynamoConnectionConfig,
14
14
  MongoConnectionConfig,
15
15
  MountedShareConnectionConfig,
@@ -696,63 +696,67 @@ def test_s3_connection_model_validate_no_iam_role():
696
696
  assert conn.iam_role_arn is None
697
697
 
698
698
 
699
- def test_databricks_delta_s3_connection_model_validate():
699
+ def test_databricks_connection_model_validate():
700
700
  payload = {
701
- "id": "11223344-5566-7788-99aa-bbccddeeff00",
702
- "name": "delta_s3",
703
- "mask_type": "file",
704
- "type": "databricks_delta_s3_connection",
705
- "base_directory": "delta/",
706
- "is_file_mask_source": True,
707
- "is_file_mask_destination": False,
708
- "bucket": "my-delta-bucket",
709
- "iam_role_arn": "arn:aws:iam::111122223333:role/delta-role",
701
+ "id": "db-id-1",
702
+ "name": "databricks",
703
+ "mask_type": "database",
704
+ "db_type": "databricks",
705
+ "server_hostname": "adb-1234.azuredatabricks.net",
706
+ "http_path": "/sql/1.0/warehouses/abcd1234",
707
+ "access_token": "dapi1234",
708
+ "catalog": "main",
709
+ "schema": "default",
710
+ "is_read_only": False,
710
711
  }
711
712
 
712
- conn = DatabricksDeltaS3ConnectionConfig.model_validate(payload)
713
+ conn = DatabricksConnectionConfig.model_validate(payload)
713
714
 
714
- assert isinstance(conn, DatabricksDeltaS3ConnectionConfig)
715
- assert conn.id == "11223344-5566-7788-99aa-bbccddeeff00"
716
- assert conn.name == "delta_s3"
717
- assert conn.bucket == "my-delta-bucket"
718
- assert conn.base_directory == "delta/"
719
- assert conn.is_file_mask_source is True
720
- assert conn.is_file_mask_destination is False
721
- assert conn.iam_role_arn == "arn:aws:iam::111122223333:role/delta-role"
715
+ assert isinstance(conn, DatabricksConnectionConfig)
716
+ assert conn.id == "db-id-1"
717
+ assert conn.server_hostname == "adb-1234.azuredatabricks.net"
718
+ assert conn.http_path == "/sql/1.0/warehouses/abcd1234"
719
+ assert conn.access_token == "dapi1234"
720
+ assert conn.catalog == "main"
721
+ assert conn.db_schema == "default"
722
+ assert conn.database_type is DatabaseType.databricks
722
723
 
723
724
 
724
- def test_databricks_delta_s3_connection_model_validate_no_iam_role():
725
+ def test_databricks_connection_model_validate_blanks_encrypted_token():
725
726
  payload = {
726
- "id": "id-delta",
727
- "name": "delta_s3",
728
- "mask_type": "file",
729
- "type": "databricks_delta_s3_connection",
730
- "base_directory": "",
731
- "is_file_mask_source": True,
732
- "is_file_mask_destination": False,
733
- "bucket": "my-delta-bucket",
727
+ "id": "db-id-2",
728
+ "name": "databricks",
729
+ "mask_type": "database",
730
+ "db_type": "databricks",
731
+ "server_hostname": "adb-1234.azuredatabricks.net",
732
+ "http_path": "/sql/1.0/warehouses/abcd1234",
733
+ "access_token_encrypted": "some_base64_here",
734
+ "catalog": "main",
735
+ "is_read_only": False,
734
736
  }
735
737
 
736
- conn = DatabricksDeltaS3ConnectionConfig.model_validate(payload)
737
- assert conn.iam_role_arn is None
738
+ conn = DatabricksConnectionConfig.model_validate(payload)
739
+
740
+ assert isinstance(conn, DatabricksConnectionConfig)
741
+ assert conn.access_token is None
738
742
 
739
743
 
740
- def test_validate_connection_dispatches_databricks_delta_s3():
744
+ def test_validate_connection_dispatches_databricks():
741
745
  payload = {
742
- "id": "aabb-ccdd",
743
- "name": "delta",
744
- "mask_type": "file",
745
- "type": "databricks_delta_s3_connection",
746
- "base_directory": "",
747
- "is_file_mask_source": False,
748
- "is_file_mask_destination": True,
749
- "bucket": "delta-bucket",
746
+ "id": "db-id-3",
747
+ "name": "databricks",
748
+ "mask_type": "database",
749
+ "db_type": "databricks",
750
+ "server_hostname": "adb-1234.azuredatabricks.net",
751
+ "http_path": "/sql/1.0/warehouses/abcd1234",
752
+ "catalog": "main",
753
+ "is_read_only": False,
750
754
  }
751
755
 
752
756
  conn = validate_connection(payload)
753
757
 
754
- assert isinstance(conn, DatabricksDeltaS3ConnectionConfig)
755
- assert conn.bucket == "delta-bucket"
758
+ assert isinstance(conn, DatabricksConnectionConfig)
759
+ assert conn.catalog == "main"
756
760
 
757
761
 
758
762
  def test_azure_connection_model_validate_blanks_encrypted_connection_string():
@@ -12,6 +12,7 @@ from datamasque.client import (
12
12
  RulesetPlanPartialUpdateRequest,
13
13
  RulesetPlanUpdateRequest,
14
14
  )
15
+ from datamasque.client.base import USER_AGENT
15
16
  from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError
16
17
 
17
18
  ADMIN = "http://admin.test"
@@ -63,11 +64,24 @@ def test_authenticate_via_jwt_login(ifm_config):
63
64
  status_code=200,
64
65
  )
65
66
  client.authenticate()
67
+ assert m.request_history[0].headers["User-Agent"] == USER_AGENT
66
68
 
67
69
  assert client.access_token == "ACC"
68
70
  assert client.refresh_token == "REF"
69
71
 
70
72
 
73
+ def test_ifm_request_carries_user_agent(authed_ifm_client):
74
+ """
75
+ Every IFM call must identify the SDK in the User-Agent header.
76
+
77
+ Covers login, refresh, and authenticated requests.
78
+ """
79
+ with requests_mock.Mocker() as m:
80
+ m.get(f"{IFM}/health/", json={"status": "ok"})
81
+ authed_ifm_client._make_request("GET", "/health/")
82
+ assert m.request_history[0].headers["User-Agent"] == USER_AGENT
83
+
84
+
71
85
  def test_authenticate_failure_raises_ifm_auth_error(ifm_config):
72
86
  client = DataMasqueIfmClient(ifm_config)
73
87
 
@@ -428,7 +428,7 @@ toml = [
428
428
 
429
429
  [[package]]
430
430
  name = "datamasque-python"
431
- version = "1.0.2"
431
+ version = "1.0.4"
432
432
  source = { editable = "." }
433
433
  dependencies = [
434
434
  { name = "pydantic" },