datamasque-python 1.0.3__tar.gz → 1.0.5__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.
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.github/workflows/ci.yml +1 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/HISTORY.rst +13 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/PKG-INFO +1 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/base.py +62 -7
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/ifm.py +7 -7
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/connection.py +2 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/pyproject.toml +1 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/setup.cfg +1 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_base.py +72 -18
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_ifm.py +14 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/uv.lock +1 -1
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.editorconfig +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.github/workflows/release-testpypi.yml +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.github/workflows/release.yml +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.gitignore +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/.readthedocs.yaml +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/CONTRIBUTING.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/LICENSE +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/MANIFEST.in +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/Makefile +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/NOTICE +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/README.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/__init__.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/connections.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/discovery.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/dmclient.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/exceptions.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/files.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/license.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/__init__.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/data_selection.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/discovery.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/dm_instance.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/files.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/ifm.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/license.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/pagination.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/ruleset.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/ruleset_library.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/runs.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/status.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/user.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/py.typed +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/ruleset_libraries.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/rulesets.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/runs.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/settings.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/users.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/Makefile +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/client.models.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/client.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/conf.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/contributing.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/history.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/index.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/installation.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/make.bat +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/modules.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/readme.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/docs/usage.rst +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/__init__.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/conftest.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/helpers.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_connections.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_discovery.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_files.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_license.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_pagination.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_ruleset_library.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_rulesets.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_runs.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_settings.py +0 -0
- {datamasque_python-1.0.3 → datamasque_python-1.0.5}/tests/test_users.py +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
History
|
|
3
3
|
=======
|
|
4
4
|
|
|
5
|
+
1.0.5 (2026-06-18)
|
|
6
|
+
------------------
|
|
7
|
+
|
|
8
|
+
* Renamed the ``DatabaseType.sql_server`` member to ``DatabaseType.mssql`` to match the DataMasque server's wire value and the sibling ``mssql_linked`` member. The value is unchanged (``"mssql"``).
|
|
9
|
+
|
|
10
|
+
1.0.4 (2026-06-09)
|
|
11
|
+
------------------
|
|
12
|
+
|
|
13
|
+
* Added ``informix`` to ``DatabaseType`` enum.
|
|
14
|
+
* 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.
|
|
15
|
+
* Send a descriptive ``User-Agent`` identifying the SDK name, version, Python interpreter, and OS.
|
|
16
|
+
* Only re-authenticate and replay on a ``401`` for requests that actually sent a token (gate the retry on ``requires_authorization``).
|
|
17
|
+
|
|
5
18
|
1.0.3 (2026-05-27)
|
|
6
19
|
------------------
|
|
7
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datamasque-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
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,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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
@@ -36,7 +36,7 @@ class DatabaseType(Enum):
|
|
|
36
36
|
mysql = "mysql"
|
|
37
37
|
oracle = "oracle"
|
|
38
38
|
mariadb = "mariadb"
|
|
39
|
-
|
|
39
|
+
mssql = "mssql"
|
|
40
40
|
redshift = "redshift"
|
|
41
41
|
dynamodb = "dynamo_db"
|
|
42
42
|
db2_luw = "db2_luw"
|
|
@@ -46,6 +46,7 @@ class DatabaseType(Enum):
|
|
|
46
46
|
mongodb = "mongodb"
|
|
47
47
|
databricks_lakebase = "databricks_lakebase"
|
|
48
48
|
databricks = "databricks"
|
|
49
|
+
informix = "informix"
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
class SnowflakeStageLocation(str, Enum):
|
|
@@ -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
|
|
78
|
-
"""
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/data_selection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datamasque_python-1.0.3 → datamasque_python-1.0.5}/datamasque/client/models/ruleset_library.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|