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.
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Transport and other machinery the public API sits on."""
@@ -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
+ )