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,51 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Where a warning should say it came from, and what kind it is."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ class SDMWarning(UserWarning):
13
+ r"""What this package says when it will answer, but not as asked.
14
+
15
+ Every warning raised from here carries it. A caller who wants none of
16
+ them, or wants all of them to stop the run, can say so once:
17
+
18
+ .. code-block:: python
19
+
20
+ warnings.filterwarnings('ignore', category=SDMWarning)
21
+ warnings.filterwarnings('error', category=SDMWarning)
22
+
23
+ It derives from `UserWarning`, which is what these were before it existed,
24
+ so a filter written against that keeps catching them.
25
+ """
26
+
27
+
28
+ _PACKAGE = __name__.split('.')[0]
29
+
30
+
31
+ def caller_stacklevel() -> int:
32
+ r"""How far up to point a warning so it lands on the caller's line.
33
+
34
+ A fixed number is right for one route into a warning and blames this
35
+ package on every other. Inference reaches the same setter through
36
+ `Schema.from_data`, and `PredictiveQuery` reaches `Schema.from_data`
37
+ through itself, so the same message is two, three or four frames from the
38
+ line the reader wants to see.
39
+
40
+ Counting every frame that belongs to this package answers for all of them,
41
+ and keeps answering when a route gains a layer.
42
+ """
43
+ depth = 1
44
+ frame = sys._getframe(1)
45
+ while frame is not None:
46
+ module = frame.f_globals.get('__name__', '')
47
+ if module != _PACKAGE and not module.startswith(f'{_PACKAGE}.'):
48
+ break
49
+ depth += 1
50
+ frame = frame.f_back
51
+ return depth
@@ -0,0 +1,421 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Asking a question the model does not take, in questions it does.
6
+
7
+ Two kinds of question have no task on the endpoint. A ranking asks which rows
8
+ of one table an entity will reach in a window; a forecast asks what a window
9
+ holds at each of several steps ahead. The models serve classification and
10
+ regression, and nothing else, so a request for either comes back refused.
11
+
12
+ Both are the same question asked repeatedly, though, and the repetition is
13
+ what the endpoint does serve:
14
+
15
+ * a ranking over candidates is, per candidate, *will this entity reach this
16
+ one?* This is a binary question whose probability orders the candidates.
17
+ * a forecast over steps is, per step, *what does this window hold?* This is the
18
+ question already being asked, with its window moved along.
19
+
20
+ Each part needs its own context, because the labels differ: what was true in
21
+ the second window is not what was true in the first, and whether an entity
22
+ reached *this* candidate is not whether it reached any. So the decomposition
23
+ happens where contexts are gathered rather than where envelopes are built,
24
+ and what comes back is merged into the one answer that was asked for.
25
+
26
+ The cost is a call per part, which for a ranking is a call per candidate.
27
+ That is the whole reason candidates must be named rather than assumed.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import math
34
+ from collections.abc import Mapping
35
+ from dataclasses import dataclass
36
+ from decimal import Decimal
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ import pandas as pd
40
+
41
+ from sdm_client.errors import StructuredDataError
42
+ from sdm_client.wire.dtypes import _decimal_dtype, _is_missing
43
+
44
+ if TYPE_CHECKING:
45
+ from predictive_query import ValidatedPredictiveQuery
46
+
47
+ from sdm_client.schema import Schema
48
+
49
+ #: More parts than this and the fan-out is the cost of the call, not a detail
50
+ #: of it. A caller who means it can raise it; nobody discovers it by accident.
51
+ MOST_PARTS = 200
52
+
53
+ RANKING = 'ranking'
54
+ FORECAST = 'forecast'
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Decomposition:
59
+ r"""The questions one question was rewritten as, and what each is about.
60
+
61
+ Carried on the request rather than re-derived from its text: a query
62
+ naming a table ``rank_events`` is not a ranking, and a candidate read back
63
+ out of the text it was written into is a string where a key was given.
64
+ """
65
+
66
+ kind: str
67
+ parts: tuple[tuple[str, Any], ...]
68
+ top: int | None = None
69
+
70
+
71
+ def parts_of(
72
+ query: ValidatedPredictiveQuery,
73
+ text: str,
74
+ candidates: list[Any] | None,
75
+ options: Mapping[str, Any],
76
+ schema: Schema | None = None,
77
+ ) -> Decomposition | None:
78
+ r"""The queries this one is asked as, or nothing if it is asked as itself.
79
+
80
+ Returns query text paired with what the part is about: the candidate it
81
+ asks after, or the step it covers. The pair matters: reading the candidate
82
+ back out of the text it was written into loses its type and trusts a
83
+ string the caller chose.
84
+
85
+ Each part is gathered like any other query, which is what gives it the
86
+ context its own labels need.
87
+ """
88
+ # Imported here because how many candidates to rank is settled by the
89
+ # relational materializer, which is built on the request this returns.
90
+ from sdm_client.materializers.relational import _candidates_to_rank
91
+
92
+ kind = str(getattr(query, 'problem_type', ''))
93
+ if kind.endswith('RANK'):
94
+ if 'prediction_statistic' in options:
95
+ # Said here: each part is a classification, and refusing there
96
+ # would name a binary_classification the caller never wrote.
97
+ raise StructuredDataError(
98
+ 'prediction_statistic applies only to a regression, and this '
99
+ 'question is a ranking, ordered by how likely each candidate '
100
+ 'is; ask it without prediction_statistic',
101
+ code='INVALID_REQUEST',
102
+ )
103
+ return Decomposition(
104
+ RANKING,
105
+ tuple(_per_candidate(query, candidates, schema)),
106
+ _candidates_to_rank(query, options),
107
+ )
108
+ if candidates is not None:
109
+ raise StructuredDataError(
110
+ f'candidates are the rows a ranking is over, and {text!r} does '
111
+ f'not rank anything. Ask it without candidates, or rank with '
112
+ f'"RANK TOP k", which is the question they are for',
113
+ code='INVALID_REQUEST',
114
+ )
115
+ if kind.endswith('FORECAST'):
116
+ if 'top_k' in options:
117
+ raise StructuredDataError(
118
+ 'top_k is how many candidates a ranking keeps, and a forecast '
119
+ 'ranks nothing; it gives back one value per step',
120
+ code='INVALID_REQUEST',
121
+ )
122
+ return Decomposition(FORECAST, tuple(_per_step(query)))
123
+ return None
124
+
125
+
126
+ def _ended(message: Any) -> str:
127
+ """``message`` with a full stop, so what is appended reads as a sentence.
128
+
129
+ Not every message this wraps ends in one, and two of them run together
130
+ without it.
131
+ """
132
+ text = str(message).rstrip()
133
+ return text if not text or text[-1] in '.!?' else f'{text}.'
134
+
135
+
136
+ def refuse_a_part_that_would_take_several_calls(
137
+ rewritten: Decomposition, asked: int, per_call: int
138
+ ) -> None:
139
+ r"""Refuse a question asked in parts over more rows than one call scores.
140
+
141
+ A part is one call. The guard above counts parts on that basis, so a
142
+ batched part would make the parts times the batches: a ranking over 200
143
+ candidates and 50,000 entities passes as 200 calls and sends 1,000.
144
+
145
+ A ranking could not be batched in any case. Each batch samples its own
146
+ context, and a batch whose context holds one class leaves the candidate
147
+ out of every row rather than out of its own, because a candidate is
148
+ reported unscored for the whole ranking.
149
+
150
+ Checked before anything is gathered, from the count the caller gave, so a
151
+ refusal costs nothing and arrives before the ceilings that are about what
152
+ gathering holds.
153
+ """
154
+ if per_call <= 0 or asked <= per_call:
155
+ return
156
+ each = 'candidate' if rewritten.kind == RANKING else 'step'
157
+ why = (
158
+ 'and a candidate is reported unscored for the whole ranking, so one '
159
+ 'cold batch would drop it from every row rather than from its own'
160
+ if rewritten.kind == RANKING
161
+ else 'and the calls would be the batches times the steps rather than '
162
+ 'the steps that were counted'
163
+ )
164
+ raise StructuredDataError(
165
+ f'{asked:,} entities are more than the {per_call:,} one call scores, '
166
+ f'and this question is asked as {len(rewritten.parts):,} of them, one '
167
+ f'per {each}. A part is not batched, {why}. Ask about fewer entities '
168
+ f'at a time',
169
+ code='UNSUPPORTED_REQUEST',
170
+ details={'entities': asked, 'rows_per_call': per_call},
171
+ )
172
+
173
+
174
+ def about_the_part(
175
+ error: StructuredDataError, rewritten: Decomposition, about: Any
176
+ ) -> StructuredDataError:
177
+ r"""``error``, said of the part it happened in, and still its own type.
178
+
179
+ A ranking's part is about one candidate, which can be dropped; a forecast's
180
+ covers a window further out than the one written, which is asked for by
181
+ asking for fewer steps. One wording served both and was false of the first.
182
+
183
+ The step named is the one that failed: anything at or above it fails alike.
184
+ The type is kept, because it is what `predict` documents.
185
+
186
+ Which part failed is always said, because it is always true. A remedy is
187
+ added only where the failure is one it addresses. An unknown model is not
188
+ answered by asking for fewer steps, and at step 1 there are no fewer to ask
189
+ for.
190
+ """
191
+ how_many = len(rewritten.parts)
192
+ first = _ended(error.args[0])
193
+ if rewritten.kind == RANKING:
194
+ said = (
195
+ f'This is the question asked for candidate {about!r}, one of '
196
+ f'{how_many} this ranking is asked as.'
197
+ )
198
+ else:
199
+ said = f'This is forecast step {about} of {how_many}.'
200
+ if (getattr(error, 'details', None) or {}).get(
201
+ 'window_outran_the_data'
202
+ ):
203
+ said += (
204
+ f" The window named above is that step's rather than the one "
205
+ f'written; ask for fewer than {about} steps if the data does '
206
+ f'not reach that far.'
207
+ )
208
+ said = f'{first} {said}'
209
+ carried = error.__class__.__new__(error.__class__)
210
+ carried.__dict__.update(error.__dict__)
211
+ carried.args = (said, *error.args[1:])
212
+ carried.message = said
213
+ return carried
214
+
215
+
216
+ def _for_each(query: ValidatedPredictiveQuery) -> str:
217
+ r"""The entity clause as it was written, filter and all.
218
+
219
+ An entity filter chooses which rows become labelled examples rather than
220
+ which rows are scored, so a part that drops it is answered from a
221
+ different population than the one asked about.
222
+ """
223
+ return f'{query.for_each} {query.entity_ast}'
224
+
225
+
226
+ def _window(query: ValidatedPredictiveQuery) -> tuple[int, int, str]:
227
+ span = query.get_final_target_aggregation().date_offset_range
228
+ return int(span.start), int(span.end), str(span.unit)
229
+
230
+
231
+ def _per_candidate(
232
+ query: ValidatedPredictiveQuery,
233
+ candidates: list[Any] | None,
234
+ schema: Schema | None = None,
235
+ ) -> list[tuple[str, Any]]:
236
+ r"""One binary question per candidate, ordered by how sure the answer is.
237
+
238
+ The endpoint has no ranking task, and a ranking it could serve would still
239
+ be over whatever the sampler reached. Asking per candidate makes the set
240
+ the caller's, which is the only version of this question with a knowable
241
+ answer.
242
+ """
243
+ aggregation = query.get_final_target_aggregation()
244
+ if aggregation is None:
245
+ raise StructuredDataError(
246
+ 'a ranking is asked one candidate at a time, about what an '
247
+ 'entity reaches in a window, and this one ranks a column '
248
+ 'instead. Rank a LIST_DISTINCT over a foreign key.',
249
+ code='INVALID_REQUEST',
250
+ )
251
+ ranked = str(aggregation.target)
252
+ if ' WHERE ' in ranked:
253
+ raise StructuredDataError(
254
+ f'the rows to rank are chosen by a filter ({ranked!r}), and each '
255
+ f'candidate is asked about with a filter of its own, so the two '
256
+ f'would have to be combined into one question this cannot write. '
257
+ f'Rank without the filter, or narrow the rows before building the '
258
+ f'schema.',
259
+ code='INVALID_REQUEST',
260
+ )
261
+ table, _, column = ranked.partition('.')
262
+ if candidates is None:
263
+ raise StructuredDataError(
264
+ f'a ranking is over a set of candidates, and this model has no '
265
+ f'ranking task to choose them with, so they have to be named: '
266
+ f'materialize(..., candidates=frame[{column!r}]). '
267
+ f'One call is made per candidate.',
268
+ code='INVALID_REQUEST',
269
+ )
270
+ unique = list(dict.fromkeys(candidates))
271
+ if not unique:
272
+ raise StructuredDataError(
273
+ 'candidates is empty, so there is nothing to rank',
274
+ code='INVALID_REQUEST',
275
+ )
276
+ _refuse_too_many(len(unique), 'candidates')
277
+ dtype = _how_the_column_travels(schema, table, column)
278
+ start, end, unit = _window(query)
279
+ return [
280
+ (
281
+ f'PREDICT COUNT({table}.* WHERE {ranked}='
282
+ f'{_as_literal(value, dtype)}, '
283
+ f'{start}, {end}, {unit}) > 0 {_for_each(query)}',
284
+ value,
285
+ )
286
+ for value in unique
287
+ ]
288
+
289
+
290
+ def _per_step(query: ValidatedPredictiveQuery) -> list[tuple[str, Any]]:
291
+ r"""One question per step, each about the window that step covers.
292
+
293
+ The aggregation is rebuilt from what it aggregates, which carries any
294
+ filter it had: `COUNT(buys.* WHERE channel="web", ...)` keeps its channel.
295
+ Only the window moves.
296
+ """
297
+ steps = int(getattr(query, 'num_forecasts', 1) or 1)
298
+ _refuse_too_many(steps, 'forecast steps')
299
+ aggregation = query.get_final_target_aggregation()
300
+ start, end, unit = _window(query)
301
+ span = end - start
302
+ return [
303
+ (
304
+ f'PREDICT {aggregation.aggr}({aggregation.target}, '
305
+ f'{start + step * span}, {end + step * span}, {unit}) '
306
+ f'{_for_each(query)}',
307
+ # The contract numbers steps from one.
308
+ step + 1,
309
+ )
310
+ for step in range(steps)
311
+ ]
312
+
313
+
314
+ def _refuse_too_many(many: int, what: str) -> None:
315
+ if many <= MOST_PARTS:
316
+ return
317
+ raise StructuredDataError(
318
+ f'{many} {what} means {many} calls, one for each, because the model '
319
+ f'has no task that answers them together. That is past the {MOST_PARTS} '
320
+ f'this will do without being told to; ask about fewer, or raise '
321
+ f'sdm_client.decompose.MOST_PARTS if the wait is what you want.',
322
+ code='INVALID_REQUEST',
323
+ )
324
+
325
+
326
+ def _how_the_column_travels(
327
+ schema: Schema | None, table: str, column: str
328
+ ) -> str | None:
329
+ r"""The dtype the schema computed for the column being ranked.
330
+
331
+ How a candidate is written is the column's question, not the value's: a key
332
+ column straddling int64 travels as text, and so must a small key from it.
333
+ """
334
+ if schema is None or table not in schema:
335
+ return None
336
+ described = schema[table]
337
+ if column not in described:
338
+ return None
339
+ return str(described[column].dtype)
340
+
341
+
342
+ def _as_literal(value: Any, dtype: str | None = None) -> str:
343
+ r"""``value`` as the query language writes it.
344
+
345
+ A candidate is a key, and a key that cannot be written as one is refused
346
+ rather than escaped into something that parses as a different question.
347
+ """
348
+ # Candidates usually come out of a frame, where a whole number is a numpy
349
+ # integer and not an ``int``, and the two have to be written alike.
350
+ if hasattr(value, 'item'):
351
+ with contextlib.suppress(AttributeError, ValueError):
352
+ value = value.item()
353
+ if _is_missing(value):
354
+ raise StructuredDataError(
355
+ 'a candidate names a row, and a missing value names none',
356
+ code='INVALID_REQUEST',
357
+ )
358
+ if isinstance(value, bool):
359
+ raise StructuredDataError(
360
+ 'a candidate names a row, and True and False do not',
361
+ code='INVALID_REQUEST',
362
+ )
363
+ if isinstance(value, Decimal):
364
+ written = _decimal_as_typed(value, dtype)
365
+ if isinstance(written, (int, float)) and Decimal(written) != value:
366
+ raise StructuredDataError(
367
+ f'the candidate {value} names no row of a column of '
368
+ f'{dtype or "numbers"}: written as one it becomes {written}, '
369
+ f'which is a different row',
370
+ code='INVALID_REQUEST',
371
+ )
372
+ value = written
373
+ if dtype == 'string' and isinstance(value, (int, float)):
374
+ if isinstance(value, float) and not math.isfinite(value):
375
+ raise StructuredDataError(
376
+ f'{value!r} cannot name a row to rank',
377
+ code='INVALID_REQUEST',
378
+ )
379
+ value = format(Decimal(str(value)), 'f')
380
+ if isinstance(value, int):
381
+ return str(value)
382
+ if isinstance(value, float):
383
+ if not math.isfinite(value) or value != int(value):
384
+ raise StructuredDataError(
385
+ f'{value!r} cannot name a row to rank',
386
+ code='INVALID_REQUEST',
387
+ )
388
+ return str(int(value))
389
+ text = str(value)
390
+ if any(character in text for character in '"\'\\,()') or not text.strip():
391
+ raise StructuredDataError(
392
+ f'the candidate {value!r} cannot be written into a query: a key '
393
+ f'holding a quote, a comma, a bracket or nothing at all would '
394
+ f'read as part of the question rather than as the row it names',
395
+ code='INVALID_REQUEST',
396
+ )
397
+ return f'"{text}"'
398
+
399
+
400
+ def _decimal_as_typed(value: Decimal, dtype: str | None = None) -> Any:
401
+ r"""``value`` as the Python type its column is typed as on the wire.
402
+
403
+ A warehouse ``DECIMAL`` reaches a caller as a :class:`~decimal.Decimal`,
404
+ and written as a string it asks after the row named ``"1"`` on a column of
405
+ integers, a question the validator refuses and nobody wrote. Which type
406
+ that is does not follow from the Python type: :func:`_decimal_dtype` is
407
+ what decides whether such a column travels as an integer, a float, or its
408
+ digits. ``dtype`` carries the column's; the value alone is used only where
409
+ the column is not known.
410
+
411
+ The digits are formatted rather than ``str``-ed, since ``str`` turns to
412
+ scientific notation past int64's width, which is the width that makes such
413
+ a column text in the first place.
414
+ """
415
+ if dtype is None:
416
+ dtype = _decimal_dtype(pd.Series([value], dtype=object))
417
+ if dtype == 'int64':
418
+ return int(value)
419
+ if dtype == 'float64':
420
+ return float(value)
421
+ return format(value, 'f')
sdm_client/errors.py ADDED
@@ -0,0 +1,177 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""The exceptions this client raises.
6
+
7
+ Every call on :class:`~sdm_client.SDMClient` raises
8
+ :class:`StructuredDataError` or one of its subclasses, so ``except
9
+ StructuredDataError`` is enough to catch anything the client reports.
10
+
11
+ Every error carries a ``code``: a short stable string meant to be branched on.
12
+ Messages are written for people and will change; codes will not.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from typing import Any
19
+
20
+ #: What to do about a context that teaches nothing.
21
+ WIDEN_THE_CONTEXT = (
22
+ 'Gather a wider context with num_context_samples, or ask about entities '
23
+ 'whose answers differ'
24
+ )
25
+
26
+ #: Entries rendered from an RFC-9457 ``invalid_params`` list before the rest
27
+ #: are summarised as a count. A rejected batch can name thousands of rows.
28
+ MAX_INVALID_PARAMS = 5
29
+
30
+
31
+ def format_invalid_params(params: Sequence[Any] | None) -> str:
32
+ r"""Render an endpoint's per-field validation diagnosis into a suffix.
33
+
34
+ A validation failure answers with RFC-9457 ``invalid_params``, naming the
35
+ exact table, row and column it rejected, while the top-level ``detail`` is
36
+ often only "Request validation failed." Dropping the list leaves the caller
37
+ with nothing to act on.
38
+
39
+ Returns the empty string when there is nothing to render, so callers can
40
+ concatenate unconditionally.
41
+ """
42
+ if not isinstance(params, (list, tuple)):
43
+ return ''
44
+ entries = []
45
+ for param in params[:MAX_INVALID_PARAMS]:
46
+ if not isinstance(param, dict):
47
+ continue
48
+ name, reason = param.get('name'), param.get('reason')
49
+ if name and reason:
50
+ entries.append(f'{name}: {reason}')
51
+ elif name or reason:
52
+ entries.append(str(name or reason))
53
+ if not entries:
54
+ return ''
55
+ omitted = len(params) - len(entries)
56
+ more = f' (and {omitted} more)' if omitted > 0 else ''
57
+ return ' ' + '; '.join(entries) + more
58
+
59
+
60
+ class StructuredDataError(Exception):
61
+ r"""Base class for every error this client raises.
62
+
63
+ Attributes:
64
+ message: The human-readable description, without the code prefix.
65
+ code: A short stable identifier, e.g. ``'TRANSPORT_ERROR'`` or
66
+ ``'INVALID_REQUEST'``. Branch on this rather than on the message.
67
+ details: Whatever structured context the failure carried, such as the
68
+ ``invalid_params`` entries from a NIM validation error. Empty when
69
+ there is none.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ message: str,
75
+ *,
76
+ code: str | None = None,
77
+ details: dict[str, Any] | None = None,
78
+ ) -> None:
79
+ super().__init__(message)
80
+ self.message = message
81
+ self.code = code
82
+ self.details = details or {}
83
+
84
+ def __str__(self) -> str:
85
+ if self.code:
86
+ return f'[{self.code}] {self.message}'
87
+ return self.message
88
+
89
+
90
+ class UnknownModelError(StructuredDataError):
91
+ r"""A request named a model this client cannot send.
92
+
93
+ The contract's ``model`` field is a closed enum, so an id it does not carry
94
+ cannot be sent at all. The message names the change that would allow it
95
+ rather than letting the request fail at the endpoint. Code:
96
+ ``UNKNOWN_MODEL``.
97
+ """
98
+
99
+ def __init__(self, model: str, known: list[str]) -> None:
100
+ super().__init__(
101
+ f'Unknown model {model!r}; this client can send: {sorted(known)}. '
102
+ f'The Universal TFM contract declares these as a closed set, so a '
103
+ f'new model id has to be added to the contract and the bindings '
104
+ f'regenerated before it can be sent.',
105
+ code='UNKNOWN_MODEL',
106
+ details={'model': model, 'known_models': sorted(known)},
107
+ )
108
+ self.model = model
109
+
110
+
111
+ class UnsupportedRequestError(StructuredDataError):
112
+ r"""The model cannot answer this request, and says so before sending it.
113
+
114
+ Raised when a request asks for something the model's profile rejects, such
115
+ as
116
+ related tables for a single-table model, an output field it does not
117
+ produce, or exceeds a limit the endpoint enforces. Code:
118
+ ``UNSUPPORTED_REQUEST``.
119
+ """
120
+
121
+ def __init__(
122
+ self, message: str, *, details: dict[str, Any] | None = None
123
+ ) -> None:
124
+ super().__init__(message, code='UNSUPPORTED_REQUEST', details=details)
125
+
126
+
127
+ class MissingExtraError(StructuredDataError):
128
+ r"""An optional dependency this call needs is not installed.
129
+
130
+ The relational engine and the connector drivers ship as extras, so the
131
+ client can be installed without them. The message names the ``pip install``
132
+ that fixes it. Code: ``MISSING_EXTRA``.
133
+ """
134
+
135
+ def __init__(self, extra: str, package: str) -> None:
136
+ super().__init__(
137
+ f'{package!r} is required for this call; install it with '
138
+ f'`pip install structured-data-models-client[{extra}]`',
139
+ code='MISSING_EXTRA',
140
+ details={'extra': extra, 'package': package},
141
+ )
142
+
143
+
144
+ class NimRequestError(StructuredDataError):
145
+ r"""The NIM answered, and the answer was an error.
146
+
147
+ Distinguished from :class:`StructuredDataError` by carrying the HTTP
148
+ :attr:`status_code`, which is the endpoint's own answer, kept as it was.
149
+
150
+ Branch on :attr:`code`, not on the status. ``INVALID_REQUEST`` means the
151
+ request has to change and sending it again cannot help; ``429``, ``502``,
152
+ ``503`` and ``504`` are worth retrying, and this client retries them. The
153
+ two can disagree: the
154
+ endpoint reports some caller mistakes with a ``500`` and an internal code,
155
+ in a sentence that names exactly what the caller got wrong, and this client
156
+ reports those as the caller's. Where it does, the endpoint's own
157
+ classification is kept in :attr:`details` under ``endpoint_code``.
158
+
159
+ Attributes:
160
+ status_code: The HTTP status the NIM returned.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ status_code: int,
166
+ *,
167
+ code: str | None,
168
+ message: str,
169
+ details: dict[str, Any] | None = None,
170
+ ) -> None:
171
+ super().__init__(message, code=code, details=details)
172
+ self.status_code = status_code
173
+
174
+ def __str__(self) -> str:
175
+ if self.code:
176
+ return f'[{self.status_code} {self.code}] {self.message}'
177
+ return f'[{self.status_code}] {self.message}'
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Model-specific implementations behind query materialization."""
6
+
7
+ from sdm_client.materializers.relational import RelationalMaterializer
8
+ from sdm_client.materializers.tabular import TabularMaterializer
9
+
10
+ __all__ = ['RelationalMaterializer', 'TabularMaterializer']