structured-data-models-client 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdm_client/__init__.py +70 -0
- sdm_client/_version.py +5 -0
- sdm_client/adapters/__init__.py +9 -0
- sdm_client/adapters/relational.py +870 -0
- sdm_client/adapters/tabular.py +580 -0
- sdm_client/base.py +300 -0
- sdm_client/client.py +1416 -0
- sdm_client/core/__init__.py +5 -0
- sdm_client/core/databricks_serving.py +611 -0
- sdm_client/core/databricks_user_agent.py +76 -0
- sdm_client/core/dates.py +278 -0
- sdm_client/core/transport.py +898 -0
- sdm_client/core/warnings.py +51 -0
- sdm_client/decompose.py +421 -0
- sdm_client/errors.py +177 -0
- sdm_client/materializers/__init__.py +10 -0
- sdm_client/materializers/relational.py +1786 -0
- sdm_client/materializers/sources.py +331 -0
- sdm_client/materializers/tabular.py +339 -0
- sdm_client/models.py +251 -0
- sdm_client/py.typed +0 -0
- sdm_client/query.py +647 -0
- sdm_client/request.py +1127 -0
- sdm_client/result.py +328 -0
- sdm_client/schema.py +3187 -0
- sdm_client/wire/__init__.py +46 -0
- sdm_client/wire/answers.py +80 -0
- sdm_client/wire/dtypes.py +701 -0
- sdm_client/wire/keys.py +363 -0
- sdm_client/wire/tables.py +50 -0
- structured_data_models_client-1.0.0.dist-info/METADATA +99 -0
- structured_data_models_client-1.0.0.dist-info/RECORD +36 -0
- structured_data_models_client-1.0.0.dist-info/WHEEL +5 -0
- structured_data_models_client-1.0.0.dist-info/licenses/LICENSE +204 -0
- structured_data_models_client-1.0.0.dist-info/licenses/NOTICE +10 -0
- structured_data_models_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""A client's requests, carried to a Databricks Model Serving endpoint.
|
|
6
|
+
|
|
7
|
+
The model is served by name inside a workspace rather than at a URL, and is
|
|
8
|
+
reached with the workspace's own identity, so a notebook needs no token. Each
|
|
9
|
+
request travels as the one ``request_json`` row the served model reads, and
|
|
10
|
+
the answer comes back as one ``response_json`` row: the frozen envelope every
|
|
11
|
+
deployed endpoint reads and writes.
|
|
12
|
+
|
|
13
|
+
What a NIM serves and this does not: sessions, and a list of models. A
|
|
14
|
+
serving endpoint hosts one model and answers one request at a time.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
import re
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from sdm_client.core.databricks_user_agent import sdm_client_user_agent
|
|
28
|
+
from sdm_client.core.transport import (
|
|
29
|
+
_check_encodable,
|
|
30
|
+
_diagnosis_in,
|
|
31
|
+
_validate_limits,
|
|
32
|
+
)
|
|
33
|
+
from sdm_client.errors import (
|
|
34
|
+
MissingExtraError,
|
|
35
|
+
NimRequestError,
|
|
36
|
+
StructuredDataError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger('sdm_client')
|
|
40
|
+
|
|
41
|
+
#: A serving endpoint scales to zero, and a cold start outlasts the minute a
|
|
42
|
+
#: NIM call is given.
|
|
43
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
44
|
+
|
|
45
|
+
# Renaming either column breaks every endpoint already deployed.
|
|
46
|
+
_REQUEST_COLUMN = 'request_json'
|
|
47
|
+
_RESPONSE_COLUMN = 'response_json'
|
|
48
|
+
|
|
49
|
+
# The gateway's own limit, in decimal MB: "correcting" it to MiB lets through
|
|
50
|
+
# 5% more, which then fails as a bare "request failed".
|
|
51
|
+
_MAX_REQUEST_BYTES = 16 * 1000 * 1000
|
|
52
|
+
|
|
53
|
+
_ERROR_CODE_RE = re.compile(r'^[A-Z][A-Z0-9_]{0,63}$')
|
|
54
|
+
|
|
55
|
+
# The SDK logs each round trip, bodies included, at DEBUG on this logger, and a
|
|
56
|
+
# serving call's body is the caller's rows.
|
|
57
|
+
_SDK_LOGGER = 'databricks.sdk'
|
|
58
|
+
_SERVING_CALL = re.compile(r'/serving-endpoints/[^/\s]+/invocations\b')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _WithoutServingBodies(logging.Filter):
|
|
62
|
+
r"""Drop the SDK's round-trip record of a serving call, and only that."""
|
|
63
|
+
|
|
64
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
65
|
+
if record.funcName != '_record_request_log':
|
|
66
|
+
return True
|
|
67
|
+
# The first line is ``METHOD /path``; a host may add a path prefix.
|
|
68
|
+
first_line = record.getMessage().split('\n', 1)[0]
|
|
69
|
+
return _SERVING_CALL.search(first_line) is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_BODY_FILTER = _WithoutServingBodies()
|
|
73
|
+
_BODY_FILTER_LOCK = threading.Lock()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _keep_rows_out_of_the_sdk_log() -> None:
|
|
77
|
+
sdk_logger = logging.getLogger(_SDK_LOGGER)
|
|
78
|
+
with _BODY_FILTER_LOCK:
|
|
79
|
+
if _BODY_FILTER not in sdk_logger.filters:
|
|
80
|
+
sdk_logger.addFilter(_BODY_FILTER)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# The served model reports a failure as ``CODE: detail``. Only the code is
|
|
84
|
+
# repeated: the detail is free text, and can quote the rows it was sent.
|
|
85
|
+
_MODEL_DIAGNOSIS = re.compile(r'([A-Z][A-Z0-9_]{2,63}): \S')
|
|
86
|
+
|
|
87
|
+
# The gateway's fixed sentences, then the model's own exception quoted. Only
|
|
88
|
+
# this prefix is trusted: an ``Error '`` further in can be the caller's text.
|
|
89
|
+
_QUOTED_MODEL_ERROR = re.compile(
|
|
90
|
+
r'Encountered an unexpected error while evaluating the model\.'
|
|
91
|
+
r"(?: [^'.]*\.)* Error '"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_NO_SESSIONS = (
|
|
95
|
+
'a Databricks Model Serving endpoint has no sessions: it answers one '
|
|
96
|
+
'request at a time and cannot pin a context. Use predict(), which sends '
|
|
97
|
+
'the context with every call'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DatabricksServingTransport:
|
|
102
|
+
r"""The wire layer to one Databricks Model Serving endpoint.
|
|
103
|
+
|
|
104
|
+
Not part of the public API surface; :meth:`SDMClient.for_databricks_serving`
|
|
105
|
+
builds one.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
endpoint: The serving endpoint's **name**, not its URL.
|
|
109
|
+
workspace_client: A ``databricks.sdk.WorkspaceClient`` to call through.
|
|
110
|
+
When omitted one is built from the ambient configuration, which is
|
|
111
|
+
how a notebook authenticates without handling a token. Either
|
|
112
|
+
way, its requests name structured-data-models-client in the
|
|
113
|
+
User-Agent. A passed ``WorkspaceClient`` itself is copied for
|
|
114
|
+
that, not changed; a subclass or test double is used as given,
|
|
115
|
+
without the name.
|
|
116
|
+
timeout: Seconds one attempt may take, and the longest the SDK keeps
|
|
117
|
+
retrying. Set on the client built here, or on the copy of a
|
|
118
|
+
passed ``WorkspaceClient``, never on the caller's own. When
|
|
119
|
+
omitted, a client built here gets 300 and a passed one keeps its
|
|
120
|
+
own. Refused with a subclass or double, which cannot be copied.
|
|
121
|
+
max_request_bytes: The gateway's size limit, checked on the encoded
|
|
122
|
+
envelope before anything is sent.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
serves_sessions = False
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
endpoint: str,
|
|
130
|
+
workspace_client: Any | None = None,
|
|
131
|
+
*,
|
|
132
|
+
timeout: float | None = None,
|
|
133
|
+
max_request_bytes: int = _MAX_REQUEST_BYTES,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._endpoint = _validate_endpoint_name(endpoint)
|
|
136
|
+
if timeout is not None:
|
|
137
|
+
_validate_limits(timeout, 0)
|
|
138
|
+
if (
|
|
139
|
+
isinstance(max_request_bytes, bool)
|
|
140
|
+
or not isinstance(max_request_bytes, int)
|
|
141
|
+
or max_request_bytes <= 0
|
|
142
|
+
):
|
|
143
|
+
raise StructuredDataError(
|
|
144
|
+
f'max_request_bytes must be a positive integer, got '
|
|
145
|
+
f'{max_request_bytes!r}',
|
|
146
|
+
code='INVALID_CONFIGURATION',
|
|
147
|
+
)
|
|
148
|
+
self._max_request_bytes = max_request_bytes
|
|
149
|
+
self._closed = False
|
|
150
|
+
_keep_rows_out_of_the_sdk_log()
|
|
151
|
+
user_agent = sdm_client_user_agent()
|
|
152
|
+
self._workspace = (
|
|
153
|
+
_named_copy(workspace_client, user_agent, timeout)
|
|
154
|
+
if workspace_client is not None
|
|
155
|
+
else _ambient_workspace(
|
|
156
|
+
float(DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout),
|
|
157
|
+
user_agent,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def endpoint(self) -> str:
|
|
163
|
+
return self._endpoint
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def url(self) -> str:
|
|
167
|
+
r"""Where requests go: the endpoint's name, since it has no URL."""
|
|
168
|
+
return f'databricks-serving:{self._endpoint}'
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
r"""Retire the transport. The workspace client owns its connections."""
|
|
172
|
+
self._closed = True
|
|
173
|
+
|
|
174
|
+
def _require_open(self) -> None:
|
|
175
|
+
if self._closed:
|
|
176
|
+
raise StructuredDataError(
|
|
177
|
+
'this client is closed; construct a new SDMClient',
|
|
178
|
+
code='INVALID_CONFIGURATION',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def health_ready(self) -> bool:
|
|
182
|
+
r"""Whether the endpoint reports itself ready to serve."""
|
|
183
|
+
self._require_open()
|
|
184
|
+
try:
|
|
185
|
+
described = self._workspace.serving_endpoints.get(
|
|
186
|
+
name=self._endpoint
|
|
187
|
+
)
|
|
188
|
+
except OSError as error:
|
|
189
|
+
raise self._error_from(error) from None
|
|
190
|
+
ready = getattr(getattr(described, 'state', None), 'ready', None)
|
|
191
|
+
return getattr(ready, 'value', ready) == 'READY'
|
|
192
|
+
|
|
193
|
+
def list_models(self) -> list[str]:
|
|
194
|
+
raise StructuredDataError(
|
|
195
|
+
'a Databricks Model Serving endpoint serves one model and does '
|
|
196
|
+
'not list the models it serves; name the model on predict()',
|
|
197
|
+
code='UNSUPPORTED_REQUEST',
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def predict(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
201
|
+
self._require_open()
|
|
202
|
+
_check_encodable(payload)
|
|
203
|
+
try:
|
|
204
|
+
records, size = _envelope(payload)
|
|
205
|
+
except (TypeError, ValueError) as error:
|
|
206
|
+
raise StructuredDataError(
|
|
207
|
+
f'this request carries a value that cannot be sent as JSON: '
|
|
208
|
+
f'{error}',
|
|
209
|
+
code='INVALID_REQUEST',
|
|
210
|
+
) from error
|
|
211
|
+
if size > self._max_request_bytes:
|
|
212
|
+
raise StructuredDataError(
|
|
213
|
+
f'this request is {size:,} bytes once wrapped for serving '
|
|
214
|
+
f'endpoint {self._endpoint!r}, above the '
|
|
215
|
+
f'{self._max_request_bytes:,} byte limit it accepts. Send '
|
|
216
|
+
f'fewer rows per call: a smaller num_context_samples, or '
|
|
217
|
+
f'fewer entities to score',
|
|
218
|
+
code='INVALID_REQUEST',
|
|
219
|
+
details={'bytes': size, 'limit': self._max_request_bytes},
|
|
220
|
+
)
|
|
221
|
+
try:
|
|
222
|
+
reply = self._workspace.serving_endpoints.query(
|
|
223
|
+
name=self._endpoint, dataframe_records=records
|
|
224
|
+
)
|
|
225
|
+
# The SDK's errors and its retry deadline are all OSError, as are
|
|
226
|
+
# requests' own; anything else is a bug, and is not dressed as a 503.
|
|
227
|
+
# The SDK's ValueError for a failed OAuth refresh escapes too; it
|
|
228
|
+
# carries no rows.
|
|
229
|
+
except OSError as error:
|
|
230
|
+
raise self._error_from(error) from None
|
|
231
|
+
return self._decoded(_response_body(reply, self._endpoint))
|
|
232
|
+
|
|
233
|
+
def _decoded(self, body: str) -> dict[str, Any]:
|
|
234
|
+
try:
|
|
235
|
+
# A deeply nested body raises RecursionError, which is a
|
|
236
|
+
# RuntimeError and would otherwise escape the error contract. Not
|
|
237
|
+
# chained: a JSONDecodeError holds the whole body, rows and all.
|
|
238
|
+
answer = json.loads(body)
|
|
239
|
+
except (ValueError, RecursionError):
|
|
240
|
+
raise StructuredDataError(
|
|
241
|
+
f'serving endpoint {self._endpoint!r} answered with a '
|
|
242
|
+
f'response that is not JSON',
|
|
243
|
+
code='TRANSPORT_ERROR',
|
|
244
|
+
) from None
|
|
245
|
+
if not isinstance(answer, dict):
|
|
246
|
+
raise StructuredDataError(
|
|
247
|
+
f'expected a JSON object from serving endpoint '
|
|
248
|
+
f'{self._endpoint!r}, got {type(answer).__name__}',
|
|
249
|
+
code='TRANSPORT_ERROR',
|
|
250
|
+
)
|
|
251
|
+
status = answer.get('status')
|
|
252
|
+
# A failure says so by its status alone; a body need not explain it.
|
|
253
|
+
if (
|
|
254
|
+
isinstance(status, int)
|
|
255
|
+
and not isinstance(status, bool)
|
|
256
|
+
and status >= 400
|
|
257
|
+
):
|
|
258
|
+
raise self._refused_in_band(status, answer)
|
|
259
|
+
return answer
|
|
260
|
+
|
|
261
|
+
def _refused_in_band(
|
|
262
|
+
self, status: int, answer: dict[str, Any]
|
|
263
|
+
) -> NimRequestError:
|
|
264
|
+
r"""A failure the served model answered with, named by its code alone.
|
|
265
|
+
|
|
266
|
+
The body's text is not repeated: it can quote the rows it was sent.
|
|
267
|
+
"""
|
|
268
|
+
said = answer.get('code')
|
|
269
|
+
code = (
|
|
270
|
+
said
|
|
271
|
+
if isinstance(said, str) and _ERROR_CODE_RE.match(said)
|
|
272
|
+
else None
|
|
273
|
+
)
|
|
274
|
+
detail = answer.get('detail')
|
|
275
|
+
if status >= 500:
|
|
276
|
+
found = _diagnosis_in(detail or answer.get('title'))
|
|
277
|
+
if found is not None:
|
|
278
|
+
return self._callers_fault(status, found[1], code, None)
|
|
279
|
+
# A 5xx can be the model naming what was wrong with the request.
|
|
280
|
+
if isinstance(detail, str):
|
|
281
|
+
code = _leading_code(detail) or code
|
|
282
|
+
return self._refusal(status, code, None)
|
|
283
|
+
|
|
284
|
+
def create_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
285
|
+
raise StructuredDataError(_NO_SESSIONS, code='SESSIONS_UNSUPPORTED')
|
|
286
|
+
|
|
287
|
+
def session_predict(
|
|
288
|
+
self, session_id: str, payload: dict[str, Any]
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
raise StructuredDataError(_NO_SESSIONS, code='SESSIONS_UNSUPPORTED')
|
|
291
|
+
|
|
292
|
+
def delete_session(self, session_id: str) -> None:
|
|
293
|
+
raise StructuredDataError(_NO_SESSIONS, code='SESSIONS_UNSUPPORTED')
|
|
294
|
+
|
|
295
|
+
def _error_from(self, error: BaseException) -> NimRequestError:
|
|
296
|
+
r"""The endpoint's failure, as the client reports any endpoint's.
|
|
297
|
+
|
|
298
|
+
The status comes from the SDK's error class and the code from the
|
|
299
|
+
served model's own diagnosis where it gave one. The SDK's message is
|
|
300
|
+
neither repeated nor logged: it can hold the request body, which is the
|
|
301
|
+
caller's rows.
|
|
302
|
+
"""
|
|
303
|
+
status = _status_of(error)
|
|
304
|
+
provider_code = _error_code_of(error)
|
|
305
|
+
said = getattr(error, 'message', None)
|
|
306
|
+
text = said if isinstance(said, str) else str(error)
|
|
307
|
+
code = _model_diagnosis(text, provider_code)
|
|
308
|
+
logger.debug(
|
|
309
|
+
'serving endpoint %r failed with %s (status %s, code %s)',
|
|
310
|
+
self._endpoint,
|
|
311
|
+
type(error).__name__,
|
|
312
|
+
status,
|
|
313
|
+
code or provider_code,
|
|
314
|
+
)
|
|
315
|
+
found = _diagnosis_in(text) if status >= 500 else None
|
|
316
|
+
if found is not None:
|
|
317
|
+
return self._callers_fault(
|
|
318
|
+
status,
|
|
319
|
+
found[1],
|
|
320
|
+
code or provider_code,
|
|
321
|
+
provider_code,
|
|
322
|
+
provider_error=type(error).__name__,
|
|
323
|
+
)
|
|
324
|
+
return self._refusal(
|
|
325
|
+
status, code, provider_code, provider_error=type(error).__name__
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _callers_fault(
|
|
329
|
+
self,
|
|
330
|
+
status: int,
|
|
331
|
+
diagnosis: str,
|
|
332
|
+
endpoint_code: str | None,
|
|
333
|
+
provider_code: str | None,
|
|
334
|
+
**details: Any,
|
|
335
|
+
) -> NimRequestError:
|
|
336
|
+
r"""A failure the endpoint called its own, reported as the caller's.
|
|
337
|
+
|
|
338
|
+
As a NIM's is: retrying one that can only fail the same way does not
|
|
339
|
+
help, and what the endpoint called it is kept in ``details``.
|
|
340
|
+
"""
|
|
341
|
+
return self._refusal(
|
|
342
|
+
status,
|
|
343
|
+
'INVALID_REQUEST',
|
|
344
|
+
provider_code,
|
|
345
|
+
endpoint_code=endpoint_code,
|
|
346
|
+
diagnosis=diagnosis,
|
|
347
|
+
**details,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _refusal(
|
|
351
|
+
self,
|
|
352
|
+
status: int,
|
|
353
|
+
code: str | None,
|
|
354
|
+
provider_code: str | None,
|
|
355
|
+
**details: Any,
|
|
356
|
+
) -> NimRequestError:
|
|
357
|
+
r"""The error for a failure known only by its status and codes."""
|
|
358
|
+
details = {'endpoint': self._endpoint, **details}
|
|
359
|
+
if provider_code is not None:
|
|
360
|
+
details['provider_code'] = provider_code
|
|
361
|
+
if code is not None:
|
|
362
|
+
return NimRequestError(
|
|
363
|
+
status,
|
|
364
|
+
code=code,
|
|
365
|
+
message=(
|
|
366
|
+
f'serving endpoint {self._endpoint!r} refused the '
|
|
367
|
+
f'request ({code})'
|
|
368
|
+
),
|
|
369
|
+
details=details,
|
|
370
|
+
)
|
|
371
|
+
suffix = f' ({provider_code})' if provider_code else ''
|
|
372
|
+
return NimRequestError(
|
|
373
|
+
status,
|
|
374
|
+
code=provider_code,
|
|
375
|
+
message=f'serving endpoint {self._endpoint!r} request failed{suffix}',
|
|
376
|
+
details=details,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _leading_code(text: str) -> str | None:
|
|
381
|
+
r"""The code ``text`` opens with, as ``CODE: detail``, if it does."""
|
|
382
|
+
found = _MODEL_DIAGNOSIS.match(text)
|
|
383
|
+
return found.group(1) if found else None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _model_diagnosis(text: str, provider_code: str | None) -> str | None:
|
|
387
|
+
r"""The code the served model opened its message with, if the SDK
|
|
388
|
+
carried one.
|
|
389
|
+
|
|
390
|
+
Read only where the model's message begins: at the start, after the
|
|
391
|
+
provider's own ``CODE: `` prefix, or in the ``Error '...'`` right after
|
|
392
|
+
the gateway's fixed wrapper. An upper-case word anywhere else can be the
|
|
393
|
+
caller's value.
|
|
394
|
+
The provider's code is skipped: ``BAD_REQUEST: ...`` is Databricks
|
|
395
|
+
describing the call, not the model describing the request.
|
|
396
|
+
"""
|
|
397
|
+
prefix = f'{provider_code}: '
|
|
398
|
+
if provider_code is not None and text.startswith(prefix):
|
|
399
|
+
text = text[len(prefix) :]
|
|
400
|
+
starts = [text]
|
|
401
|
+
quoted = _QUOTED_MODEL_ERROR.match(text)
|
|
402
|
+
if quoted is not None:
|
|
403
|
+
starts.append(text[quoted.end() :])
|
|
404
|
+
for start in starts:
|
|
405
|
+
code = _leading_code(start)
|
|
406
|
+
if code is not None and code != provider_code:
|
|
407
|
+
return code
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _validate_endpoint_name(name: object) -> str:
|
|
412
|
+
r"""Reject anything URL-shaped, without echoing it.
|
|
413
|
+
|
|
414
|
+
A serving endpoint is named, not addressed, and a pasted workspace URL can
|
|
415
|
+
carry userinfo credentials or a token query parameter. So the URL scan runs
|
|
416
|
+
before any check that echoes the value, and the rejection never quotes it.
|
|
417
|
+
``@`` is in the reject set because a schemeless ``user:token@host`` carries
|
|
418
|
+
userinfo without any of the other markers.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
StructuredDataError: ``INVALID_CONFIGURATION``, with a message that
|
|
422
|
+
does not contain ``name``.
|
|
423
|
+
"""
|
|
424
|
+
if not isinstance(name, str) or not name.strip():
|
|
425
|
+
refusal = 'endpoint must be a non-empty serving endpoint name'
|
|
426
|
+
elif any(token in name for token in ('://', '/', '?', '#', '@')):
|
|
427
|
+
refusal = 'endpoint must be a serving endpoint name, not a URL'
|
|
428
|
+
elif name != name.strip():
|
|
429
|
+
refusal = 'endpoint name has surrounding whitespace'
|
|
430
|
+
else:
|
|
431
|
+
return name
|
|
432
|
+
raise StructuredDataError(refusal, code='INVALID_CONFIGURATION')
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _envelope(request: Mapping[str, Any]) -> tuple[list[dict[str, str]], int]:
|
|
436
|
+
r"""The ``dataframe_records`` to send, and their size on the wire.
|
|
437
|
+
|
|
438
|
+
The size is of the body the gateway weighs, which re-escapes every quote in
|
|
439
|
+
the payload (~1.15x), and the records returned are the ones measured.
|
|
440
|
+
``ensure_ascii`` (the default) makes ``len`` the byte count.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
ValueError: If ``request`` holds a value with no strict JSON form,
|
|
444
|
+
such as ``NaN``.
|
|
445
|
+
"""
|
|
446
|
+
payload = json.dumps(request, allow_nan=False, separators=(',', ':'))
|
|
447
|
+
records = [{_REQUEST_COLUMN: payload}]
|
|
448
|
+
body = json.dumps({'dataframe_records': records}, separators=(',', ':'))
|
|
449
|
+
return records, len(body)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _response_body(reply: Any, endpoint: str) -> str:
|
|
453
|
+
r"""The single canonical response string the contract promises.
|
|
454
|
+
|
|
455
|
+
``reply`` is whatever the SDK returned: an object with ``predictions`` or a
|
|
456
|
+
mapping holding it. Each row may be a mapping or an object.
|
|
457
|
+
|
|
458
|
+
Raises:
|
|
459
|
+
StructuredDataError: ``TRANSPORT_ERROR``, if there is not exactly one
|
|
460
|
+
row, or it holds no ``response_json`` string.
|
|
461
|
+
"""
|
|
462
|
+
predictions = getattr(reply, 'predictions', None)
|
|
463
|
+
if predictions is None and isinstance(reply, Mapping):
|
|
464
|
+
predictions = reply.get('predictions')
|
|
465
|
+
if not isinstance(predictions, list) or len(predictions) != 1:
|
|
466
|
+
raise StructuredDataError(
|
|
467
|
+
f'serving endpoint {endpoint!r} did not return exactly one '
|
|
468
|
+
f'prediction row',
|
|
469
|
+
code='TRANSPORT_ERROR',
|
|
470
|
+
)
|
|
471
|
+
row = predictions[0]
|
|
472
|
+
if isinstance(row, Mapping):
|
|
473
|
+
body = row.get(_RESPONSE_COLUMN)
|
|
474
|
+
else:
|
|
475
|
+
body = getattr(row, _RESPONSE_COLUMN, None)
|
|
476
|
+
if not isinstance(body, str):
|
|
477
|
+
raise StructuredDataError(
|
|
478
|
+
f'serving endpoint {endpoint!r} answered with a row that has no '
|
|
479
|
+
f'{_RESPONSE_COLUMN!r} string column',
|
|
480
|
+
code='TRANSPORT_ERROR',
|
|
481
|
+
)
|
|
482
|
+
return body
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _status_of(error: BaseException) -> int:
|
|
486
|
+
r"""Map a Databricks SDK error onto an HTTP-equivalent status.
|
|
487
|
+
|
|
488
|
+
Matched by class name so the SDK need not be importable to read this
|
|
489
|
+
module, and a version bump that moves the classes cannot break the mapping
|
|
490
|
+
silently. An unclassified failure is 503 rather than 500, because it is
|
|
491
|
+
usually the endpoint being unavailable, and 503 is what callers retry.
|
|
492
|
+
"""
|
|
493
|
+
names = {cls.__name__ for cls in type(error).__mro__}
|
|
494
|
+
if names & {'PermissionDenied', 'Unauthenticated'}:
|
|
495
|
+
return 403 if 'PermissionDenied' in names else 401
|
|
496
|
+
if 'NotFound' in names:
|
|
497
|
+
return 404
|
|
498
|
+
if 'ResourceConflict' in names:
|
|
499
|
+
return 409
|
|
500
|
+
if names & {'TooManyRequests', 'RequestLimitExceeded'}:
|
|
501
|
+
return 429
|
|
502
|
+
if names & {
|
|
503
|
+
'DeadlineExceeded',
|
|
504
|
+
'OperationTimeout',
|
|
505
|
+
'TimeoutError',
|
|
506
|
+
'Timeout',
|
|
507
|
+
}:
|
|
508
|
+
return 504
|
|
509
|
+
if 'BadRequest' in names:
|
|
510
|
+
return 400
|
|
511
|
+
status = getattr(error, 'status_code', None)
|
|
512
|
+
return status if isinstance(status, int) else 503
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _error_code_of(error: BaseException) -> str | None:
|
|
516
|
+
r"""The provider's error code, when enum-shaped enough to be non-secret."""
|
|
517
|
+
code = getattr(error, 'error_code', None)
|
|
518
|
+
if isinstance(code, str) and _ERROR_CODE_RE.match(code):
|
|
519
|
+
return code
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _named_copy(
|
|
524
|
+
workspace_client: Any, user_agent: tuple[str, str], timeout: float | None
|
|
525
|
+
) -> Any:
|
|
526
|
+
r"""A client like ``workspace_client`` whose requests name this SDK, and
|
|
527
|
+
are bounded by ``timeout`` when one is given.
|
|
528
|
+
|
|
529
|
+
The caller's client is left as it was: its config is copied, which shares
|
|
530
|
+
the credentials by reference. Only the SDK's own class is rebuilt, since a
|
|
531
|
+
subclass or a test double would lose what it adds; it is used as given.
|
|
532
|
+
|
|
533
|
+
Raises:
|
|
534
|
+
StructuredDataError: ``INVALID_CONFIGURATION``, if ``timeout`` is
|
|
535
|
+
given for a client that is used as given.
|
|
536
|
+
"""
|
|
537
|
+
try:
|
|
538
|
+
from databricks.sdk import WorkspaceClient
|
|
539
|
+
except ImportError:
|
|
540
|
+
return _as_given(workspace_client, timeout)
|
|
541
|
+
if type(workspace_client) is not WorkspaceClient:
|
|
542
|
+
return _as_given(workspace_client, timeout)
|
|
543
|
+
config = workspace_client.config.copy()
|
|
544
|
+
if timeout is not None:
|
|
545
|
+
# Private SDK state: a release that reshapes it must not have the
|
|
546
|
+
# bound set on the caller's own config instead.
|
|
547
|
+
if not isinstance(getattr(config, '_inner', None), dict):
|
|
548
|
+
raise StructuredDataError(
|
|
549
|
+
'timeout cannot be applied to a copy of this databricks-sdk '
|
|
550
|
+
"version's WorkspaceClient; set http_timeout_seconds and "
|
|
551
|
+
"retry_timeout_seconds on the client's own Config instead",
|
|
552
|
+
code='INVALID_CONFIGURATION',
|
|
553
|
+
)
|
|
554
|
+
# copy() shares the attribute dict with the caller's config, so a
|
|
555
|
+
# bound set without this would change their client too.
|
|
556
|
+
config._inner = dict(config._inner)
|
|
557
|
+
# The SDK retries for its own retry_timeout_seconds whatever one
|
|
558
|
+
# attempt's timeout, so both are bounded by the caller's.
|
|
559
|
+
config.http_timeout_seconds = float(timeout)
|
|
560
|
+
config.retry_timeout_seconds = math.ceil(timeout)
|
|
561
|
+
# Before the client is built: its ApiClient reads both timeouts and the
|
|
562
|
+
# User-Agent once, then.
|
|
563
|
+
return WorkspaceClient(config=config.with_user_agent_extra(*user_agent))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _as_given(workspace_client: Any, timeout: float | None) -> Any:
|
|
567
|
+
kind = type(workspace_client).__name__
|
|
568
|
+
if timeout is not None:
|
|
569
|
+
# Ignoring it would leave the caller believing a bound they asked
|
|
570
|
+
# for is in force.
|
|
571
|
+
raise StructuredDataError(
|
|
572
|
+
f'timeout is set on a copy of the workspace client, and a {kind} '
|
|
573
|
+
f'cannot be copied: only a databricks.sdk.WorkspaceClient itself '
|
|
574
|
+
f'can. Set http_timeout_seconds and retry_timeout_seconds on its '
|
|
575
|
+
f'Config instead',
|
|
576
|
+
code='INVALID_CONFIGURATION',
|
|
577
|
+
)
|
|
578
|
+
logger.debug(
|
|
579
|
+
'workspace_client is a %s, not a WorkspaceClient; its requests are '
|
|
580
|
+
'sent as given, without the structured-data-models-client '
|
|
581
|
+
'User-Agent entry',
|
|
582
|
+
kind,
|
|
583
|
+
)
|
|
584
|
+
return workspace_client
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _ambient_workspace(timeout: float, user_agent: tuple[str, str]) -> Any:
|
|
588
|
+
try:
|
|
589
|
+
from databricks.sdk import WorkspaceClient
|
|
590
|
+
from databricks.sdk.core import Config
|
|
591
|
+
except ImportError:
|
|
592
|
+
raise MissingExtraError(
|
|
593
|
+
'databricks-serving', 'databricks-sdk'
|
|
594
|
+
) from None
|
|
595
|
+
try:
|
|
596
|
+
# The SDK retries for its own retry_timeout_seconds whatever one
|
|
597
|
+
# attempt's timeout, so both are bounded by the caller's.
|
|
598
|
+
config = Config(
|
|
599
|
+
http_timeout_seconds=timeout,
|
|
600
|
+
retry_timeout_seconds=math.ceil(timeout),
|
|
601
|
+
)
|
|
602
|
+
# Before the client is built: it reads the User-Agent once, then.
|
|
603
|
+
return WorkspaceClient(config=config.with_user_agent_extra(*user_agent))
|
|
604
|
+
except Exception as error:
|
|
605
|
+
raise StructuredDataError(
|
|
606
|
+
f'could not authenticate to Databricks from the ambient '
|
|
607
|
+
f'configuration ({type(error).__name__}). Run this in a Databricks '
|
|
608
|
+
f'notebook, set DATABRICKS_HOST and a credential, or pass '
|
|
609
|
+
f'workspace_client=',
|
|
610
|
+
code='INVALID_CONFIGURATION',
|
|
611
|
+
) from None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""How a prediction request names this SDK to Databricks.
|
|
6
|
+
|
|
7
|
+
Databricks attributes partner traffic by a ``<partner>_<product>/<version>``
|
|
8
|
+
entry in the User-Agent. structured-data-models-connectors keeps its own copy for the
|
|
9
|
+
connections it opens, so the serving extra need not bring it and its pandas;
|
|
10
|
+
a test holds the two to the same identifier.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from sdm_client._version import __version__
|
|
18
|
+
|
|
19
|
+
#: The product half of the partner identifier: a name an administrator can
|
|
20
|
+
#: tie to this client, never a model or endpoint. It is the distribution's
|
|
21
|
+
#: name: Databricks wants an underscore between partner and product and
|
|
22
|
+
#: its SDK takes ``[a-zA-Z0-9_.+-]``, so the dashes stay and that underscore
|
|
23
|
+
#: is the only one. It is a wire value that partner telemetry keys on, so it
|
|
24
|
+
#: changes only with the distribution's name.
|
|
25
|
+
DATABRICKS_PARTNER = 'nvidia'
|
|
26
|
+
DATABRICKS_PRODUCT = 'structured-data-models-client'
|
|
27
|
+
|
|
28
|
+
_PACKAGE_VERSION_RE = re.compile(
|
|
29
|
+
r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
|
30
|
+
r'(?:(a|b|rc)(0|[1-9]\d*))?'
|
|
31
|
+
r'(?:(?:\.dev(0|[1-9]\d*))|(?:\.post(0|[1-9]\d*)))?$'
|
|
32
|
+
)
|
|
33
|
+
_PRERELEASE_NAMES = {'a': 'alpha', 'b': 'beta', 'rc': 'rc'}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def databricks_product_version(package_version: str) -> str:
|
|
37
|
+
r"""Return the SDK release in the SemVer form Databricks accepts.
|
|
38
|
+
|
|
39
|
+
Stable SDK releases already use SemVer. Python pre-releases use PEP 440's
|
|
40
|
+
compact spelling, so translate the pre-release, development and post-release
|
|
41
|
+
forms supported by this project. Other PEP 440 forms are rejected
|
|
42
|
+
deliberately: an invented fallback would claim the wrong SDK release.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If ``package_version`` has no such form.
|
|
46
|
+
"""
|
|
47
|
+
match = _PACKAGE_VERSION_RE.fullmatch(package_version)
|
|
48
|
+
if match is None:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f'Package version {package_version!r} cannot be represented in the '
|
|
51
|
+
'Databricks User-Agent; expected a supported PEP 440 release '
|
|
52
|
+
'based on X.Y.Z'
|
|
53
|
+
)
|
|
54
|
+
major, minor, patch, prerelease, number, dev, post = match.groups()
|
|
55
|
+
version = f'{major}.{minor}.{patch}'
|
|
56
|
+
if prerelease is not None:
|
|
57
|
+
version = f'{version}-{_PRERELEASE_NAMES[prerelease]}.{number}'
|
|
58
|
+
if dev is not None:
|
|
59
|
+
separator = '.' if prerelease is not None else '-'
|
|
60
|
+
version = f'{version}{separator}dev.{dev}'
|
|
61
|
+
if post is not None:
|
|
62
|
+
version = f'{version}+post.{post}'
|
|
63
|
+
return version
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sdm_client_user_agent() -> tuple[str, str]:
|
|
67
|
+
r"""The ``(name, version)`` entry naming this SDK, for
|
|
68
|
+
``Config.with_user_agent_extra``.
|
|
69
|
+
|
|
70
|
+
An extra rather than the ``product`` slot, which names the application
|
|
71
|
+
and may be the caller's own.
|
|
72
|
+
"""
|
|
73
|
+
return (
|
|
74
|
+
f'{DATABRICKS_PARTNER}_{DATABRICKS_PRODUCT}',
|
|
75
|
+
databricks_product_version(__version__),
|
|
76
|
+
)
|