datamasque-python 1.1.3__tar.gz → 1.1.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.
Files changed (80) hide show
  1. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/HISTORY.rst +15 -0
  2. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/PKG-INFO +6 -1
  3. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/README.rst +5 -0
  4. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/base.py +3 -0
  5. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/exceptions.py +16 -0
  6. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/connection.py +10 -4
  7. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/dm_instance.py +13 -0
  8. datamasque_python-1.1.5/datamasque/client/spcs.py +179 -0
  9. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/client.rst +8 -0
  10. datamasque_python-1.1.5/docs/usage.rst +49 -0
  11. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/pyproject.toml +1 -1
  12. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_connections.py +30 -0
  13. datamasque_python-1.1.5/tests/test_spcs.py +157 -0
  14. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/uv.lock +90 -90
  15. datamasque_python-1.1.3/docs/usage.rst +0 -21
  16. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.editorconfig +0 -0
  17. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.github/workflows/ci.yml +0 -0
  18. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.github/workflows/release-testpypi.yml +0 -0
  19. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.github/workflows/release.yml +0 -0
  20. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.gitignore +0 -0
  21. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/.readthedocs.yaml +0 -0
  22. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/CONTRIBUTING.rst +0 -0
  23. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/LICENSE +0 -0
  24. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/MANIFEST.in +0 -0
  25. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/Makefile +0 -0
  26. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/NOTICE +0 -0
  27. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/__init__.py +0 -0
  28. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/connections.py +0 -0
  29. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/discovery.py +0 -0
  30. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/discovery_configs.py +0 -0
  31. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/dmclient.py +0 -0
  32. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/files.py +0 -0
  33. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/ifm.py +0 -0
  34. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/license.py +0 -0
  35. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/__init__.py +0 -0
  36. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/data_selection.py +0 -0
  37. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/discovery.py +0 -0
  38. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/discovery_config.py +0 -0
  39. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/files.py +0 -0
  40. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/git.py +0 -0
  41. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/ifm.py +0 -0
  42. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/license.py +0 -0
  43. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/pagination.py +0 -0
  44. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/ruleset.py +0 -0
  45. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/ruleset_library.py +0 -0
  46. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/runs.py +0 -0
  47. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/status.py +0 -0
  48. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/models/user.py +0 -0
  49. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/py.typed +0 -0
  50. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/ruleset_libraries.py +0 -0
  51. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/rulesets.py +0 -0
  52. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/runs.py +0 -0
  53. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/settings.py +0 -0
  54. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/datamasque/client/users.py +0 -0
  55. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/Makefile +0 -0
  56. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/client.models.rst +0 -0
  57. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/conf.py +0 -0
  58. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/contributing.rst +0 -0
  59. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/history.rst +0 -0
  60. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/index.rst +0 -0
  61. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/installation.rst +0 -0
  62. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/make.bat +0 -0
  63. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/modules.rst +0 -0
  64. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/docs/readme.rst +0 -0
  65. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/setup.cfg +0 -0
  66. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/__init__.py +0 -0
  67. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/conftest.py +0 -0
  68. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/helpers.py +0 -0
  69. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_base.py +0 -0
  70. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_discovery.py +0 -0
  71. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_discovery_configs.py +0 -0
  72. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_files.py +0 -0
  73. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_ifm.py +0 -0
  74. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_license.py +0 -0
  75. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_pagination.py +0 -0
  76. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_ruleset_library.py +0 -0
  77. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_rulesets.py +0 -0
  78. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_runs.py +0 -0
  79. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_settings.py +0 -0
  80. {datamasque_python-1.1.3 → datamasque_python-1.1.5}/tests/test_users.py +0 -0
@@ -2,6 +2,21 @@
2
2
  History
3
3
  =======
4
4
 
5
+ 1.1.5 (2026-06-29)
6
+ ------------------
7
+
8
+ * Added support for DataMasque deployments on Snowpark Container Services (SPCS):
9
+
10
+ * Added ``spcs_pat`` to ``DataMasqueInstanceConfig`` for authenticating through the SPCS app gateway.
11
+ * Added ``SpcsGatewayAuthError``, raised when the gateway rejects the PAT.
12
+ * Added the ``spcs`` option to ``SnowflakeStageLocation``.
13
+ * Made several ``SnowflakeConnectionConfig`` fields optional, since SPCS-staged connections leave them unset.
14
+
15
+ 1.1.4 (2026-06-29)
16
+ ------------------
17
+
18
+ * Added ``DatabaseType.saphana`` for SAP HANA support.
19
+
5
20
  1.1.3 (2026-06-26)
6
21
  ------------------
7
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamasque-python
3
- Version: 1.1.3
3
+ Version: 1.1.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/
@@ -74,6 +74,11 @@ Authentication is performed on the first request if ``authenticate()`` is not ca
74
74
  and is automatically retried once on a 401 response.
75
75
  ``client.healthcheck()`` is available as a lightweight readiness probe that does not consume credentials.
76
76
 
77
+ For a DataMasque instance hosted on Snowpark Container Services (SPCS)
78
+ (a ``*.snowflakecomputing.app`` ``base_url``),
79
+ pass a Snowflake Programmatic Access Token as ``spcs_pat`` on ``DataMasqueInstanceConfig``.
80
+ See the `usage docs <https://datamasque-python.readthedocs.io/en/latest/usage.html>`_ for details.
81
+
77
82
  Error handling
78
83
  ==============
79
84
 
@@ -42,6 +42,11 @@ Authentication is performed on the first request if ``authenticate()`` is not ca
42
42
  and is automatically retried once on a 401 response.
43
43
  ``client.healthcheck()`` is available as a lightweight readiness probe that does not consume credentials.
44
44
 
45
+ For a DataMasque instance hosted on Snowpark Container Services (SPCS)
46
+ (a ``*.snowflakecomputing.app`` ``base_url``),
47
+ pass a Snowflake Programmatic Access Token as ``spcs_pat`` on ``DataMasqueInstanceConfig``.
48
+ See the `usage docs <https://datamasque-python.readthedocs.io/en/latest/usage.html>`_ for details.
49
+
45
50
  Error handling
46
51
  ==============
47
52
 
@@ -21,6 +21,7 @@ from datamasque.client.exceptions import (
21
21
  DataMasqueTransportError,
22
22
  )
23
23
  from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
24
+ from datamasque.client.spcs import install_spcs_gateway_auth
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
@@ -137,6 +138,8 @@ class BaseClient:
137
138
  self.verify_ssl = connection_config.verify_ssl
138
139
  self.token_source = connection_config.token_source
139
140
  self._session = _build_session(self.verify_ssl)
141
+ if connection_config.spcs_pat:
142
+ install_spcs_gateway_auth(self._session, connection_config.spcs_pat)
140
143
 
141
144
  @contextmanager
142
145
  def _maybe_suppress_insecure_warning(self) -> Iterator[None]:
@@ -84,6 +84,22 @@ class IfmAuthError(DataMasqueIfmError):
84
84
  """Raised when the IFM client cannot obtain or refresh a JWT (e.g. invalid credentials, missing scope)."""
85
85
 
86
86
 
87
+ class SpcsGatewayAuthError(DataMasqueException):
88
+ """
89
+ Raised when a Snowflake SPCS app gateway rejects the configured `spcs_pat`.
90
+
91
+ The message includes the Snowflake-provided detail, request id,
92
+ and a hint at the likely cause
93
+ (for example an expired token, or a network policy that excludes your IP).
94
+
95
+ Deliberately a direct subclass of `DataMasqueException` rather than
96
+ `DataMasqueApiError`:
97
+ the client's 401 re-authenticate-and-retry path keys off `DataMasqueApiError`,
98
+ so keeping this outside that subtree
99
+ ensures a gateway rejection aborts immediately instead of looping.
100
+ """
101
+
102
+
87
103
  class RunNotCancellableError(DataMasqueUserError):
88
104
  """
89
105
  Raised when `cancel_run` is called against a run that is no longer eligible for cancellation.
@@ -48,6 +48,7 @@ class DatabaseType(Enum):
48
48
  databricks_lakebase = "databricks_lakebase"
49
49
  databricks = "databricks"
50
50
  informix = "informix"
51
+ saphana = "saphana"
51
52
 
52
53
 
53
54
  class SnowflakeStageLocation(str, Enum):
@@ -56,6 +57,7 @@ class SnowflakeStageLocation(str, Enum):
56
57
  local = "local" # Not supported for production use
57
58
  aws_s3 = "aws_s3"
58
59
  azure_blob_storage = "azure_blob_storage"
60
+ spcs = "spcs" # DataMasque running inside Snowflake SPCS; staged on the container's own storage
59
61
 
60
62
 
61
63
  class SseSelection(Enum):
@@ -233,10 +235,14 @@ class SnowflakeConnectionConfig(ConnectionConfig):
233
235
  """
234
236
 
235
237
  database: str
236
- user: str
237
- snowflake_account_id: str
238
- snowflake_warehouse: str
239
- snowflake_storage_integration_name: str
238
+ # Optional because DataMasque-in-SPCS connections leave these unset: the agent uses the
239
+ # container's OAuth token + SNOWFLAKE_HOST/SNOWFLAKE_ACCOUNT env and the app-owned QUERY_WAREHOUSE,
240
+ # so user/account/storage-integration/warehouse are null for stage_location=spcs. Mirrors the app's
241
+ # canonical model (agent .../schemas/connection/connection.py), which types these `| None = None`.
242
+ user: Optional[str] = None
243
+ snowflake_account_id: Optional[str] = None
244
+ snowflake_warehouse: Optional[str] = None
245
+ snowflake_storage_integration_name: Optional[str] = None
240
246
  host: str = ""
241
247
  port: Optional[int] = None
242
248
  db_schema: Optional[str] = Field(default=None, alias="schema")
@@ -20,6 +20,11 @@ class DataMasqueInstanceConfig(BaseModel):
20
20
  the client prepends it with `Token ` when sending the `Authorization` header.
21
21
  The client calls `token_source` on each authentication attempt,
22
22
  so the callable is free to fetch and refresh tokens out-of-band (e.g. from a secrets manager).
23
+
24
+ `spcs_pat` is an optional Snowflake Programmatic Access Token
25
+ for reaching a DataMasque instance hosted on Snowpark Container Services (SPCS).
26
+ It layers underneath whichever DataMasque auth method
27
+ (`password` or `token_source`) you choose.
23
28
  """
24
29
 
25
30
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -29,6 +34,14 @@ class DataMasqueInstanceConfig(BaseModel):
29
34
  password: Optional[str] = None
30
35
  verify_ssl: bool = True
31
36
  token_source: Optional[Callable[[], str]] = None
37
+ spcs_pat: Optional[str] = None
38
+ """Snowflake Programmatic Access Token
39
+ for a DataMasque instance hosted on Snowpark Container Services (SPCS),
40
+ where `base_url` ends in `.snowflakecomputing.app`.
41
+
42
+ Create the token in Snowsight (User profile → Programmatic access tokens)
43
+ for an account that can reach the SPCS app.
44
+ Leave unset for instances that are not hosted on SPCS."""
32
45
 
33
46
  @model_validator(mode="after")
34
47
  def _validate_auth_source(self) -> "DataMasqueInstanceConfig":
@@ -0,0 +1,179 @@
1
+ """
2
+ Snowflake SPCS app gateway authentication for `DataMasqueClient`.
3
+
4
+ When a DataMasque instance is hosted on Snowpark Container Services (SPCS),
5
+ its app ingress (`*.snowflakecomputing.app`) fronts every request
6
+ with a Snowflake gateway that must be cleared first.
7
+ We authenticate to the gateway with a Programmatic Access Token (PAT),
8
+ sent on `X-SF-SPCS-Authorization: Snowflake Token="<PAT>"`.
9
+ The gateway accepts the PAT on this alternate header
10
+ and strips it before forwarding to the container,
11
+ so DataMasque's own `Authorization: Token <key>` flow rides through untouched.
12
+
13
+ `install_spcs_gateway_auth` attaches this behaviour to a client's `requests.Session`:
14
+ it sets the header on the session
15
+ (so it is sent on every request, including the unauthenticated login)
16
+ and registers a response hook
17
+ that turns a gateway-originated rejection into a clear `SpcsGatewayAuthError`.
18
+ """
19
+
20
+ import re
21
+ from typing import Any, Optional
22
+
23
+ import requests
24
+
25
+ from datamasque.client.exceptions import SpcsGatewayAuthError
26
+
27
+ SPCS_GATEWAY_AUTH_HEADER = "X-SF-SPCS-Authorization"
28
+
29
+ # Body-shape discriminators for SPCS gateway error responses.
30
+ # The gateway emits JSON with `responseType` (ERROR_<UPPER_SNAKE>), `requestId`
31
+ # (canonical UUID), and `detail` (free text). All three must be present and
32
+ # match these patterns for the body to count as gateway-originated.
33
+ _GATEWAY_RESPONSE_TYPE_RE = re.compile(r"^ERROR_[A-Z][A-Z0-9_]+$")
34
+ _UUID_RE = re.compile(
35
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+ # Header-shape discriminators for "this response transited a Snowflake SPCS
40
+ # gateway". The Server header and the `sfc-ss-` cookie name prefix both appear
41
+ # on every gateway-handled response (success and error alike) and aren't
42
+ # plausible to spoof by accident.
43
+ _SPCS_GATEWAY_SERVER_VALUE = "_"
44
+ _SPCS_COOKIE_PREFIX = "sfc-ss-"
45
+
46
+
47
+ def _has_spcs_gateway_header_signature(response: requests.Response) -> bool:
48
+ """
49
+ True if at least one header-level Snowflake gateway marker is present.
50
+
51
+ Looks for either `Server: _` (the gateway's literal Server header value)
52
+ or any `Set-Cookie` carrying the `sfc-ss-` cookie name prefix.
53
+ Either is sufficient — both indicate the response was emitted by,
54
+ or transited, Snowflake's SPCS ingress.
55
+ """
56
+ if response.headers.get("server", "").strip() == _SPCS_GATEWAY_SERVER_VALUE:
57
+ return True
58
+ # `Set-Cookie` may appear multiple times; `requests` flattens duplicates
59
+ # via a comma-separated value in `.headers`, but our prefix substring
60
+ # check is order- and count-insensitive.
61
+ return _SPCS_COOKIE_PREFIX in response.headers.get("set-cookie", "")
62
+
63
+
64
+ def _is_spcs_gateway_error_body(response: requests.Response) -> Optional[dict]:
65
+ """
66
+ Return the parsed body iff it is a structurally-valid gateway error.
67
+
68
+ All four conditions must hold:
69
+ 1. The body parses as JSON and is a dict.
70
+ 2. Keys `responseType`, `requestId`, `detail` are all present and string-typed.
71
+ 3. `responseType` matches `^ERROR_<UPPER_SNAKE>$`.
72
+ 4. `requestId` is a canonical 8-4-4-4-12 UUID.
73
+
74
+ Returns the parsed dict (truthy) on match, `None` on miss.
75
+ """
76
+ try:
77
+ data = response.json()
78
+ except ValueError:
79
+ return None
80
+ if not isinstance(data, dict):
81
+ return None
82
+ response_type = data.get("responseType")
83
+ request_id = data.get("requestId")
84
+ detail = data.get("detail")
85
+ if not (isinstance(response_type, str) and isinstance(request_id, str) and isinstance(detail, str)):
86
+ return None
87
+ if _GATEWAY_RESPONSE_TYPE_RE.match(response_type) and _UUID_RE.match(request_id):
88
+ return data
89
+ return None
90
+
91
+
92
+ def _hint_for_gateway_detail(detail: str) -> str:
93
+ """Map common Snowflake gateway `detail` strings to a one-line cause hint."""
94
+ d = (detail or "").lower()
95
+ if "network policy" in d:
96
+ return (
97
+ "PAT requires a network policy attached to the user (or account) "
98
+ "that permits your current public IP. Run `CREATE NETWORK POLICY "
99
+ "... ALLOWED_IP_LIST = ('<your.ip>/32')` and `ALTER USER <pat-user> "
100
+ "SET NETWORK_POLICY = <policy>`."
101
+ )
102
+ if "invalid" in d and "token" in d:
103
+ return (
104
+ "PAT is malformed, expired, or revoked. Create a new PAT in Snowsight "
105
+ "(User profile -> Programmatic access tokens) and update `spcs_pat`."
106
+ )
107
+ if "expired" in d:
108
+ return "PAT has expired. Create a fresh one in Snowsight and update `spcs_pat`."
109
+ if "authentication" in d or "unauthorized" in d:
110
+ return (
111
+ "Generic auth rejection. Verify the PAT was created by a user that "
112
+ "has access to this SPCS app, and that any account-level network "
113
+ "policy includes your current public IP."
114
+ )
115
+ return "Unknown gateway rejection — see the Snowflake `detail` string above and the Snowflake PAT docs."
116
+
117
+
118
+ def _check_spcs_gateway_response(response: requests.Response) -> None:
119
+ """
120
+ Raise `SpcsGatewayAuthError` iff `response` is a gateway-originated rejection.
121
+
122
+ Two-layer discriminator — both must hold:
123
+ * **Body originated at the gateway**:
124
+ strict shape match on the JSON body
125
+ (multiple fields, typed, with format constraints)
126
+ via `_is_spcs_gateway_error_body`.
127
+ * **Response transited an SPCS gateway**:
128
+ header signature confirms via `_has_spcs_gateway_header_signature`.
129
+
130
+ Either layer alone could in principle false-positive on an unrelated upstream
131
+ that happened to emit one of those signals;
132
+ the conjunction is what makes the check robust.
133
+ Legitimate DataMasque 401s (DRF `{"detail": "..."}`)
134
+ have the gateway header signature but fail the body shape —
135
+ so they correctly flow through
136
+ to the client's normal re-auth-and-retry path untouched.
137
+ """
138
+ if response.status_code not in (401, 403):
139
+ return
140
+ if not _has_spcs_gateway_header_signature(response):
141
+ return
142
+ data = _is_spcs_gateway_error_body(response)
143
+ if data is None:
144
+ return
145
+
146
+ response_type = data["responseType"]
147
+ request_id = data["requestId"]
148
+ detail = data["detail"]
149
+ hint = _hint_for_gateway_detail(detail)
150
+ raise SpcsGatewayAuthError(
151
+ f"SPCS gateway rejected the PAT (HTTP {response.status_code}, "
152
+ f"{response_type}). The request never reached DataMasque.\n"
153
+ f' Snowflake said: "{detail}"\n'
154
+ f" Snowflake reqId: {request_id}\n"
155
+ f" Likely cause: {hint}\n"
156
+ f" Fix in Snowsight on the account hosting this SPCS app, then retry."
157
+ )
158
+
159
+
160
+ def _spcs_gateway_response_hook(response: requests.Response, *args: Any, **kwargs: Any) -> None:
161
+ """`requests` response hook: raise on a gateway-originated auth rejection."""
162
+ _check_spcs_gateway_response(response)
163
+
164
+
165
+ def install_spcs_gateway_auth(session: requests.Session, pat: str) -> None:
166
+ """
167
+ Configure `session` to authenticate to a Snowflake SPCS app gateway.
168
+
169
+ Sets the `X-SF-SPCS-Authorization` header on the session
170
+ (so it rides on every request, including the unauthenticated login)
171
+ and registers a response hook
172
+ that raises `SpcsGatewayAuthError` on a gateway rejection.
173
+
174
+ Scoping is automatic:
175
+ the client's session only ever talks to its own `base_url`,
176
+ so there is no need to match per-request hosts.
177
+ """
178
+ session.headers[SPCS_GATEWAY_AUTH_HEADER] = f'Snowflake Token="{pat}"'
179
+ session.hooks["response"].append(_spcs_gateway_response_hook)
@@ -60,6 +60,14 @@ datamasque.client.files module
60
60
  :undoc-members:
61
61
  :show-inheritance:
62
62
 
63
+ datamasque.client.spcs module
64
+ -----------------------------
65
+
66
+ .. automodule:: datamasque.client.spcs
67
+ :members:
68
+ :undoc-members:
69
+ :show-inheritance:
70
+
63
71
  datamasque.client.ifm module
64
72
  ----------------------------
65
73
 
@@ -0,0 +1,49 @@
1
+ =====
2
+ Usage
3
+ =====
4
+
5
+ To use DataMasque Python in a project:
6
+
7
+ .. code-block:: python
8
+
9
+ from datamasque.client import DataMasqueClient
10
+ from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
11
+
12
+ config = DataMasqueInstanceConfig(
13
+ base_url="https://datamasque.example.com",
14
+ username="api_user",
15
+ password="api_password",
16
+ )
17
+ client = DataMasqueClient(config)
18
+ client.authenticate()
19
+
20
+ for connection in client.list_connections():
21
+ print(connection.name)
22
+
23
+ Connecting to an SPCS-hosted instance
24
+ =====================================
25
+
26
+ When DataMasque is hosted on Snowpark Container Services (SPCS),
27
+ its `base_url` ends in `.snowflakecomputing.app`
28
+ and requests must first clear the Snowflake gateway.
29
+ Pass a Snowflake Programmatic Access Token (PAT) as `spcs_pat`
30
+ and the client clears the gateway for you,
31
+ independently of your DataMasque `username`/`password` (or `token_source`) auth.
32
+
33
+ .. code-block:: python
34
+
35
+ config = DataMasqueInstanceConfig(
36
+ base_url="https://my-app.snowflakecomputing.app",
37
+ username="api_user",
38
+ password="api_password",
39
+ spcs_pat="<snowflake-programmatic-access-token>",
40
+ )
41
+ client = DataMasqueClient(config)
42
+ client.authenticate()
43
+
44
+ Create the PAT in Snowsight (User profile → Programmatic access tokens)
45
+ for an account that can reach the SPCS app.
46
+ If the gateway rejects the PAT
47
+ (for example it has expired, or a network policy excludes your IP),
48
+ the client raises `SpcsGatewayAuthError`
49
+ with the Snowflake-provided detail and a hint at the likely cause.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datamasque-python"
3
- version = "1.1.3"
3
+ version = "1.1.5"
4
4
  description = "Official Python client for the DataMasque data-masking API."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -952,6 +952,36 @@ def test_snowflake_connection_model_validate_with_stage_location():
952
952
  assert conn.password is None
953
953
 
954
954
 
955
+ def test_snowflake_connection_model_validate_with_spcs_stage_location():
956
+ """
957
+ A Snowflake connection staged in SPCS must deserialise (regression for ui-testing MR !185).
958
+
959
+ When DataMasque runs inside Snowflake SPCS it saves connections with
960
+ `snowflake_stage_location=spcs`. Listing connections deserialises every
961
+ one, so an unknown stage value used to raise `ValidationError` and break
962
+ `create_or_update_connection` for unrelated connections on a shared instance.
963
+ """
964
+ payload = {
965
+ "id": "a1b2c3d4-0000-0000-0000-000000000000",
966
+ "name": "snowflake_spcs",
967
+ "mask_type": "database",
968
+ "db_type": "snowflake",
969
+ "user": "snowman",
970
+ "database": "icicle",
971
+ "snowflake_account_id": "ABCDEF-123456",
972
+ "snowflake_warehouse": "warehouse1",
973
+ "snowflake_storage_integration_name": "mysi",
974
+ "snowflake_stage_location": "spcs",
975
+ }
976
+
977
+ conn = SnowflakeConnectionConfig.model_validate(payload)
978
+
979
+ assert conn.snowflake_stage_location is SnowflakeStageLocation.spcs
980
+ # SPCS staging carries no external-storage fields.
981
+ assert conn.s3_bucket_name is None
982
+ assert conn.snowflake_azure_container_name is None
983
+
984
+
955
985
  def test_snowflake_connection_model_validate_without_stage_location():
956
986
  payload = {
957
987
  "id": "id-3",
@@ -0,0 +1,157 @@
1
+ """Tests for Snowflake SPCS app gateway auth (`spcs_pat` on the client)."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ import requests_mock
7
+
8
+ from datamasque.client import DataMasqueClient
9
+ from datamasque.client.exceptions import SpcsGatewayAuthError
10
+ from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
11
+ from datamasque.client.spcs import (
12
+ SPCS_GATEWAY_AUTH_HEADER,
13
+ _hint_for_gateway_detail,
14
+ )
15
+
16
+ BASE_URL = "https://my-app.snowflakecomputing.app"
17
+ PAT = "PAT123"
18
+ EXPECTED_HEADER = 'Snowflake Token="PAT123"'
19
+ VALID_UUID = "12345678-1234-1234-1234-123456789abc"
20
+
21
+ # Headers/body that together mark a response as a gateway-originated rejection.
22
+ GATEWAY_HEADERS = {"Server": "_"}
23
+
24
+
25
+ def _gateway_error_body(detail="Invalid token", response_type="ERROR_INVALID_TOKEN"):
26
+ return {"responseType": response_type, "requestId": VALID_UUID, "detail": detail}
27
+
28
+
29
+ @pytest.fixture
30
+ def spcs_config():
31
+ return DataMasqueInstanceConfig(
32
+ base_url=BASE_URL,
33
+ username="api_user",
34
+ password="api_password",
35
+ spcs_pat=PAT,
36
+ )
37
+
38
+
39
+ @pytest.fixture
40
+ def spcs_client(spcs_config):
41
+ client = DataMasqueClient(spcs_config)
42
+ client.token = "Token dm-token" # pretend we're already authenticated with DM
43
+ return client
44
+
45
+
46
+ def test_spcs_header_present_on_authenticated_request(spcs_client):
47
+ with requests_mock.Mocker() as m:
48
+ m.get(f"{BASE_URL}/api/anything/", json={}, status_code=200)
49
+ spcs_client.make_request("GET", "/api/anything/")
50
+
51
+ assert m.last_request.headers[SPCS_GATEWAY_AUTH_HEADER] == EXPECTED_HEADER
52
+ # DM's own auth header rides alongside, untouched.
53
+ assert m.last_request.headers["Authorization"] == "Token dm-token"
54
+
55
+
56
+ def test_spcs_header_present_on_login_request(spcs_config):
57
+ """The header must ride on the unauthenticated login POST too (it must clear the gateway)."""
58
+ client = DataMasqueClient(spcs_config)
59
+ with requests_mock.Mocker() as m:
60
+ m.post(f"{BASE_URL}/api/auth/token/login/", json={"key": "k"}, status_code=200)
61
+ client.authenticate()
62
+
63
+ assert m.last_request.headers[SPCS_GATEWAY_AUTH_HEADER] == EXPECTED_HEADER
64
+ # Login is unauthenticated — no DM Authorization header on this request.
65
+ assert "Authorization" not in m.last_request.headers
66
+
67
+
68
+ def test_no_spcs_pat_means_no_header_and_no_hook(client):
69
+ """The default client (no spcs_pat) is entirely unaffected."""
70
+ assert SPCS_GATEWAY_AUTH_HEADER not in client._session.headers
71
+ assert client._session.hooks["response"] == []
72
+
73
+ client.token = "Token dm-token"
74
+ with requests_mock.Mocker() as m:
75
+ m.get("http://test-server/api/anything/", json={}, status_code=200)
76
+ client.make_request("GET", "/api/anything/")
77
+ assert SPCS_GATEWAY_AUTH_HEADER not in m.last_request.headers
78
+
79
+
80
+ def test_gateway_401_raises_and_does_not_retry(spcs_config):
81
+ """A gateway rejection aborts immediately — no re-auth, no retry loop."""
82
+ with patch.object(DataMasqueClient, "authenticate") as mock_auth:
83
+ client = DataMasqueClient(spcs_config)
84
+ client.token = "Token dm-token"
85
+ with requests_mock.Mocker() as m:
86
+ # A single response: a second call would 404 and fail the test loudly.
87
+ m.get(
88
+ f"{BASE_URL}/api/anything/",
89
+ json=_gateway_error_body(),
90
+ status_code=401,
91
+ headers=GATEWAY_HEADERS,
92
+ )
93
+ with pytest.raises(SpcsGatewayAuthError) as exc:
94
+ client.make_request("GET", "/api/anything/")
95
+
96
+ assert m.call_count == 1
97
+ mock_auth.assert_not_called()
98
+ # The helpful hint is surfaced.
99
+ assert "PAT is malformed, expired, or revoked" in str(exc.value)
100
+ assert VALID_UUID in str(exc.value)
101
+
102
+
103
+ def test_normal_dm_401_still_retries(spcs_config):
104
+ """A genuine DataMasque 401 (no gateway signature) flows to the normal re-auth retry."""
105
+ client = DataMasqueClient(spcs_config)
106
+ with requests_mock.Mocker() as m:
107
+ m.post(f"{BASE_URL}/api/auth/token/login/", json={"key": "k"}, status_code=200)
108
+ m.get(
109
+ f"{BASE_URL}/api/anything/",
110
+ [
111
+ # DRF-shaped 401, no gateway Server header → not a gateway rejection.
112
+ {"json": {"detail": "Authentication credentials were not provided."}, "status_code": 401},
113
+ {"json": {"ok": True}, "status_code": 200},
114
+ ],
115
+ )
116
+ # Should NOT raise SpcsGatewayAuthError; should re-auth and succeed.
117
+ response = client.make_request("GET", "/api/anything/")
118
+
119
+ assert response.status_code == 200
120
+ assert client.token == "Token k" # re-auth happened
121
+
122
+
123
+ def test_gateway_signature_without_body_shape_passes_through(spcs_config):
124
+ """Gateway header present but DM-shaped body → treated as a normal DM 401, not a gateway rejection."""
125
+ client = DataMasqueClient(spcs_config)
126
+ with requests_mock.Mocker() as m:
127
+ m.post(f"{BASE_URL}/api/auth/token/login/", json={"key": "k"}, status_code=200)
128
+ m.get(
129
+ f"{BASE_URL}/api/anything/",
130
+ [
131
+ {"json": {"detail": "nope"}, "status_code": 401, "headers": GATEWAY_HEADERS},
132
+ {"json": {"ok": True}, "status_code": 200},
133
+ ],
134
+ )
135
+ response = client.make_request("GET", "/api/anything/") # must not raise
136
+
137
+ assert response.status_code == 200
138
+
139
+
140
+ @pytest.mark.parametrize(
141
+ "detail, expected_substring",
142
+ [
143
+ ("Request failed network policy check", "network policy"),
144
+ ("Invalid token supplied", "malformed, expired, or revoked"),
145
+ ("The token has expired", "PAT has expired"),
146
+ ("Unauthorized request", "Generic auth rejection"),
147
+ ("Something totally unexpected", "Unknown gateway rejection"),
148
+ ],
149
+ )
150
+ def test_hint_for_gateway_detail(detail, expected_substring):
151
+ assert expected_substring in _hint_for_gateway_detail(detail)
152
+
153
+
154
+ def test_spcs_pat_coexists_with_password_and_token_source():
155
+ """`spcs_pat` is orthogonal to the password/token_source XOR — both combos validate."""
156
+ DataMasqueInstanceConfig(base_url=BASE_URL, username="u", password="p", spcs_pat=PAT)
157
+ DataMasqueInstanceConfig(base_url=BASE_URL, username="u", token_source=lambda: "t", spcs_pat=PAT)
@@ -300,7 +300,7 @@ wheels = [
300
300
 
301
301
  [package.optional-dependencies]
302
302
  toml = [
303
- { name = "tomli", marker = "python_full_version < '3.10'" },
303
+ { name = "tomli" },
304
304
  ]
305
305
 
306
306
  [[package]]
@@ -423,12 +423,12 @@ wheels = [
423
423
 
424
424
  [package.optional-dependencies]
425
425
  toml = [
426
- { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" },
426
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
427
427
  ]
428
428
 
429
429
  [[package]]
430
430
  name = "datamasque-python"
431
- version = "1.1.3"
431
+ version = "1.1.5"
432
432
  source = { editable = "." }
433
433
  dependencies = [
434
434
  { name = "pydantic" },
@@ -503,7 +503,7 @@ name = "exceptiongroup"
503
503
  version = "1.3.1"
504
504
  source = { registry = "https://pypi.org/simple" }
505
505
  dependencies = [
506
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
506
+ { name = "typing-extensions" },
507
507
  ]
508
508
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
509
509
  wheels = [
@@ -518,7 +518,7 @@ resolution-markers = [
518
518
  "python_full_version < '3.10'",
519
519
  ]
520
520
  dependencies = [
521
- { name = "tzdata", marker = "python_full_version < '3.10'" },
521
+ { name = "tzdata" },
522
522
  ]
523
523
  sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" }
524
524
  wheels = [
@@ -535,7 +535,7 @@ resolution-markers = [
535
535
  "python_full_version == '3.10.*'",
536
536
  ]
537
537
  dependencies = [
538
- { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
538
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
539
539
  ]
540
540
  sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" }
541
541
  wheels = [
@@ -582,7 +582,7 @@ name = "importlib-metadata"
582
582
  version = "8.7.1"
583
583
  source = { registry = "https://pypi.org/simple" }
584
584
  dependencies = [
585
- { name = "zipp", marker = "python_full_version < '3.10'" },
585
+ { name = "zipp" },
586
586
  ]
587
587
  sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
588
588
  wheels = [
@@ -1070,13 +1070,13 @@ resolution-markers = [
1070
1070
  "python_full_version < '3.10'",
1071
1071
  ]
1072
1072
  dependencies = [
1073
- { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
1074
- { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
1075
- { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
1076
- { name = "packaging", marker = "python_full_version < '3.10'" },
1077
- { name = "pluggy", marker = "python_full_version < '3.10'" },
1078
- { name = "pygments", marker = "python_full_version < '3.10'" },
1079
- { name = "tomli", marker = "python_full_version < '3.10'" },
1073
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1074
+ { name = "exceptiongroup" },
1075
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
1076
+ { name = "packaging" },
1077
+ { name = "pluggy" },
1078
+ { name = "pygments" },
1079
+ { name = "tomli" },
1080
1080
  ]
1081
1081
  sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
1082
1082
  wheels = [
@@ -1093,13 +1093,13 @@ resolution-markers = [
1093
1093
  "python_full_version == '3.10.*'",
1094
1094
  ]
1095
1095
  dependencies = [
1096
- { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
1097
- { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
1098
- { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
1099
- { name = "packaging", marker = "python_full_version >= '3.10'" },
1100
- { name = "pluggy", marker = "python_full_version >= '3.10'" },
1101
- { name = "pygments", marker = "python_full_version >= '3.10'" },
1102
- { name = "tomli", marker = "python_full_version == '3.10.*'" },
1096
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1097
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
1098
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } },
1099
+ { name = "packaging" },
1100
+ { name = "pluggy" },
1101
+ { name = "pygments" },
1102
+ { name = "tomli", marker = "python_full_version < '3.11'" },
1103
1103
  ]
1104
1104
  sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
1105
1105
  wheels = [
@@ -1200,24 +1200,24 @@ resolution-markers = [
1200
1200
  "python_full_version < '3.10'",
1201
1201
  ]
1202
1202
  dependencies = [
1203
- { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
1204
- { name = "babel", marker = "python_full_version < '3.10'" },
1205
- { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
1206
- { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
1207
- { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
1208
- { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
1209
- { name = "jinja2", marker = "python_full_version < '3.10'" },
1210
- { name = "packaging", marker = "python_full_version < '3.10'" },
1211
- { name = "pygments", marker = "python_full_version < '3.10'" },
1212
- { name = "requests", marker = "python_full_version < '3.10'" },
1213
- { name = "snowballstemmer", marker = "python_full_version < '3.10'" },
1214
- { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" },
1215
- { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" },
1216
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" },
1217
- { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" },
1218
- { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" },
1219
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" },
1220
- { name = "tomli", marker = "python_full_version < '3.10'" },
1203
+ { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } },
1204
+ { name = "babel" },
1205
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1206
+ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
1207
+ { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" } },
1208
+ { name = "importlib-metadata" },
1209
+ { name = "jinja2" },
1210
+ { name = "packaging" },
1211
+ { name = "pygments" },
1212
+ { name = "requests" },
1213
+ { name = "snowballstemmer" },
1214
+ { name = "sphinxcontrib-applehelp" },
1215
+ { name = "sphinxcontrib-devhelp" },
1216
+ { name = "sphinxcontrib-htmlhelp" },
1217
+ { name = "sphinxcontrib-jsmath" },
1218
+ { name = "sphinxcontrib-qthelp" },
1219
+ { name = "sphinxcontrib-serializinghtml" },
1220
+ { name = "tomli" },
1221
1221
  ]
1222
1222
  sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
1223
1223
  wheels = [
@@ -1232,23 +1232,23 @@ resolution-markers = [
1232
1232
  "python_full_version == '3.10.*'",
1233
1233
  ]
1234
1234
  dependencies = [
1235
- { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
1236
- { name = "babel", marker = "python_full_version == '3.10.*'" },
1237
- { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" },
1238
- { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
1239
- { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
1240
- { name = "jinja2", marker = "python_full_version == '3.10.*'" },
1241
- { name = "packaging", marker = "python_full_version == '3.10.*'" },
1242
- { name = "pygments", marker = "python_full_version == '3.10.*'" },
1243
- { name = "requests", marker = "python_full_version == '3.10.*'" },
1244
- { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" },
1245
- { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" },
1246
- { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" },
1247
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" },
1248
- { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" },
1249
- { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" },
1250
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" },
1251
- { name = "tomli", marker = "python_full_version == '3.10.*'" },
1235
+ { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } },
1236
+ { name = "babel" },
1237
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1238
+ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
1239
+ { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } },
1240
+ { name = "jinja2" },
1241
+ { name = "packaging" },
1242
+ { name = "pygments" },
1243
+ { name = "requests" },
1244
+ { name = "snowballstemmer" },
1245
+ { name = "sphinxcontrib-applehelp" },
1246
+ { name = "sphinxcontrib-devhelp" },
1247
+ { name = "sphinxcontrib-htmlhelp" },
1248
+ { name = "sphinxcontrib-jsmath" },
1249
+ { name = "sphinxcontrib-qthelp" },
1250
+ { name = "sphinxcontrib-serializinghtml" },
1251
+ { name = "tomli" },
1252
1252
  ]
1253
1253
  sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
1254
1254
  wheels = [
@@ -1263,23 +1263,23 @@ resolution-markers = [
1263
1263
  "python_full_version == '3.11.*'",
1264
1264
  ]
1265
1265
  dependencies = [
1266
- { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
1267
- { name = "babel", marker = "python_full_version == '3.11.*'" },
1268
- { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
1269
- { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
1270
- { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
1271
- { name = "jinja2", marker = "python_full_version == '3.11.*'" },
1272
- { name = "packaging", marker = "python_full_version == '3.11.*'" },
1273
- { name = "pygments", marker = "python_full_version == '3.11.*'" },
1274
- { name = "requests", marker = "python_full_version == '3.11.*'" },
1275
- { name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
1276
- { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
1277
- { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
1278
- { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
1279
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
1280
- { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
1281
- { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
1282
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
1266
+ { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } },
1267
+ { name = "babel" },
1268
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1269
+ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
1270
+ { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } },
1271
+ { name = "jinja2" },
1272
+ { name = "packaging" },
1273
+ { name = "pygments" },
1274
+ { name = "requests" },
1275
+ { name = "roman-numerals" },
1276
+ { name = "snowballstemmer" },
1277
+ { name = "sphinxcontrib-applehelp" },
1278
+ { name = "sphinxcontrib-devhelp" },
1279
+ { name = "sphinxcontrib-htmlhelp" },
1280
+ { name = "sphinxcontrib-jsmath" },
1281
+ { name = "sphinxcontrib-qthelp" },
1282
+ { name = "sphinxcontrib-serializinghtml" },
1283
1283
  ]
1284
1284
  sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
1285
1285
  wheels = [
@@ -1294,23 +1294,23 @@ resolution-markers = [
1294
1294
  "python_full_version >= '3.12'",
1295
1295
  ]
1296
1296
  dependencies = [
1297
- { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
1298
- { name = "babel", marker = "python_full_version >= '3.12'" },
1299
- { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
1300
- { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
1301
- { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
1302
- { name = "jinja2", marker = "python_full_version >= '3.12'" },
1303
- { name = "packaging", marker = "python_full_version >= '3.12'" },
1304
- { name = "pygments", marker = "python_full_version >= '3.12'" },
1305
- { name = "requests", marker = "python_full_version >= '3.12'" },
1306
- { name = "roman-numerals", marker = "python_full_version >= '3.12'" },
1307
- { name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
1308
- { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
1309
- { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
1310
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
1311
- { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
1312
- { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
1313
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
1297
+ { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } },
1298
+ { name = "babel" },
1299
+ { name = "colorama", marker = "sys_platform == 'win32'" },
1300
+ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
1301
+ { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } },
1302
+ { name = "jinja2" },
1303
+ { name = "packaging" },
1304
+ { name = "pygments" },
1305
+ { name = "requests" },
1306
+ { name = "roman-numerals" },
1307
+ { name = "snowballstemmer" },
1308
+ { name = "sphinxcontrib-applehelp" },
1309
+ { name = "sphinxcontrib-devhelp" },
1310
+ { name = "sphinxcontrib-htmlhelp" },
1311
+ { name = "sphinxcontrib-jsmath" },
1312
+ { name = "sphinxcontrib-qthelp" },
1313
+ { name = "sphinxcontrib-serializinghtml" },
1314
1314
  ]
1315
1315
  sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
1316
1316
  wheels = [
@@ -1,21 +0,0 @@
1
- =====
2
- Usage
3
- =====
4
-
5
- To use DataMasque Python in a project:
6
-
7
- .. code-block:: python
8
-
9
- from datamasque.client import DataMasqueClient
10
- from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
11
-
12
- config = DataMasqueInstanceConfig(
13
- base_url="https://datamasque.example.com",
14
- username="api_user",
15
- password="api_password",
16
- )
17
- client = DataMasqueClient(config)
18
- client.authenticate()
19
-
20
- for connection in client.list_connections():
21
- print(connection.name)