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/query.py ADDED
@@ -0,0 +1,647 @@
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 in PQL, and gathering the data that answers it.
6
+
7
+ A predictive query says *what* to predict. It never says *which rows* to
8
+ predict for. That is always an explicit argument because a query can only
9
+ enumerate rows that are already in the tables:
10
+
11
+ >>> query = PredictiveQuery(
12
+ ... 'PREDICT SUM(orders.price, 0, 30, days) FOR EACH users.user_id',
13
+ ... {'users': users_df, 'orders': orders_df},
14
+ ... )
15
+ >>> request = query.materialize(indices=[1, 2, 3])
16
+
17
+ :meth:`~PredictiveQuery.materialize` is the gathering step, and it is explicit
18
+ because it is the expensive one: it parses the query, finds labelled examples
19
+ in history, samples the neighbourhood of each entity, and encodes the result.
20
+ Nothing is sent. What comes back is a :class:`~sdm_client.Request` gathered
21
+ for one model. Which one follows the schema's shape unless ``model`` says
22
+ otherwise, and the model asked to answer it has to be that same one.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from collections.abc import Mapping, Sequence
29
+ from numbers import Integral
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ import pandas as pd
33
+ from predictive_query import RELATIONAL, TABULAR
34
+
35
+ from sdm_client.decompose import (
36
+ Decomposition,
37
+ about_the_part,
38
+ parts_of,
39
+ refuse_a_part_that_would_take_several_calls,
40
+ )
41
+ from sdm_client.errors import MissingExtraError, StructuredDataError
42
+ from sdm_client.materializers import RelationalMaterializer, TabularMaterializer
43
+ from sdm_client.materializers.relational import (
44
+ refuse_more_entities_than_fit_in_memory,
45
+ rows_one_call_scores,
46
+ rows_per_call_asked_for,
47
+ )
48
+ from sdm_client.models import RELATIONAL_LIMITS, Model, spec_for
49
+ from sdm_client.request import Request
50
+ from sdm_client.schema import _MAX_CATEGORICAL_CARDINALITY as _MAX_CATEGORICAL
51
+ from sdm_client.schema import Schema
52
+
53
+ if TYPE_CHECKING:
54
+ from predictive_query import Profile, ValidatedPredictiveQuery
55
+
56
+
57
+ class PredictiveQuery:
58
+ r"""A question expressed in PQL and answered from the tables in its schema.
59
+
60
+ The query is resolved and validated when :meth:`materialize` builds a
61
+ request.
62
+
63
+ Args:
64
+ query: PQL query text.
65
+ data: A schema or mapping of table names to DataFrames.
66
+
67
+ Raises:
68
+ StructuredDataError: If the query is empty or the data is invalid.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ query: str,
74
+ data: Schema | Mapping[str, pd.DataFrame],
75
+ ) -> None:
76
+ if not isinstance(query, str) or not query.strip():
77
+ raise StructuredDataError(
78
+ 'query must be a non-empty string', code='INVALID_REQUEST'
79
+ )
80
+ self.text = query
81
+ self.schema = (
82
+ data if isinstance(data, Schema) else Schema.from_data(dict(data))
83
+ )
84
+ self._tabular_materializer = TabularMaterializer(self.schema)
85
+ self._relational_materializer = RelationalMaterializer(self.schema)
86
+
87
+ def materialize(
88
+ self,
89
+ *,
90
+ indices: Sequence[Any] | None = None,
91
+ predict: pd.DataFrame | None = None,
92
+ num_context_samples: int | None = None,
93
+ model: str | Model | None = None,
94
+ candidates: Sequence[Any] | None = None,
95
+ task: str | None = None,
96
+ **options: Any,
97
+ ) -> Request:
98
+ r"""Build a request with the data needed to answer this query.
99
+
100
+ The request takes one of three paths: predict=<frame> uses the single
101
+ table as context, the tabular route builds the request directly in
102
+ pandas, and the relational route gathers the tables as a graph. Either
103
+ single-table path samples the context down to what the endpoint
104
+ accepts, and says so when it does.
105
+
106
+ ``model`` chooses between the last two. Left unsaid, the schema's shape
107
+ decides, and the shape that counts is the one
108
+ :attr:`~sdm_client.Request.is_relational` reports: a relationship sends
109
+ a schema relationally as surely as a second table does, so a lone table
110
+ that points at itself is gathered as the graph it is. Name a model when
111
+ that default is not what you want. A single table with no
112
+ relationships is a graph of one node, and the relational model answers
113
+ it.
114
+
115
+ Args:
116
+ indices: Keys of rows already present in the tables. Mutually
117
+ exclusive with ``predict``. On the relational route, more than
118
+ the endpoint scores in one call are gathered as several calls,
119
+ and :meth:`~sdm_client.SDMClient.predict` answers them as one.
120
+ A question asked in parts, a ranking or a forecast, is one
121
+ call per part and is refused rather than batched.
122
+ predict: Rows supplied directly for scoring. Supported for
123
+ single-table schemas and mutually exclusive with ``indices``.
124
+ num_context_samples: For ``indices`` requests, the maximum number
125
+ of labelled context rows to use. Capped at what the endpoint
126
+ accepts. When omitted, a single-table request uses every
127
+ eligible row up to that cap, and a relational one uses up to
128
+ 1,000.
129
+ model: The model this request is being gathered for, as a handle
130
+ such as ``KumoRelational()`` or a model id. Defaults to the
131
+ schema's shape.
132
+ candidates: The rows a ranking is over. A ranking is answered as
133
+ one question per candidate, so the set has to be named rather
134
+ than assumed, and one call is made for each.
135
+ task: The kind of question this is, when the target does not
136
+ settle it. Authoritative, and single-table only: passing it
137
+ with a relational or decomposed query is refused.
138
+ **options: Relational sampling options, such as ``num_neighbors``
139
+ and ``anchor_time``; ``prediction_statistic``: what a
140
+ regression's prediction reports of its predicted distribution,
141
+ ``'median'`` (the default) or ``'mean'``; and
142
+ ``rows_per_call``: how many entities one call scores, which
143
+ defaults to what the endpoint takes, 10,000 or 200 for a
144
+ temporal link prediction, and may only be lowered. Lower it
145
+ for a graph wide enough that a full batch outruns the 30 MiB
146
+ body, or for a deployment holding to less than this client
147
+ knows of.
148
+
149
+ Returns:
150
+ A :class:`~sdm_client.Request` carrying the data.
151
+
152
+ Raises:
153
+ UnknownModelError: If ``model`` is an id the contract does not
154
+ carry.
155
+ StructuredDataError: If neither ``indices`` nor ``predict`` is
156
+ given, if both are, if an option is not one this accepts, or if
157
+ ``model`` cannot answer a schema of this shape.
158
+ """
159
+ spec = spec_for(model) if model is not None else None
160
+ in_place = self.schema.source is not None
161
+ relational = (
162
+ spec.profile == RELATIONAL
163
+ if spec is not None
164
+ else in_place
165
+ or len(self.schema) > 1
166
+ or bool(self.schema.relationships)
167
+ )
168
+ if in_place and predict is not None and relational:
169
+ raise StructuredDataError(
170
+ 'predict= carries rows, and a schema over tables in their '
171
+ 'source is sampled there instead; name the entities to score '
172
+ 'with indices=',
173
+ code='INVALID_REQUEST',
174
+ )
175
+ if in_place and not relational:
176
+ raise StructuredDataError(
177
+ 'a schema over tables in their source is sampled with SQL, '
178
+ 'which only the relational model reads; the tabular model '
179
+ 'takes rows in memory. Use model=KumoRelational() with '
180
+ 'indices=, or build the schema with Schema.from_data from the '
181
+ 'rows you want',
182
+ code='INVALID_REQUEST',
183
+ )
184
+ self._validate_materialize_args(
185
+ relational=relational,
186
+ model_id=spec.model_id if spec is not None else None,
187
+ len_schema=len(self.schema),
188
+ relationships=len(self.schema.relationships),
189
+ indices=indices,
190
+ predict=predict,
191
+ num_context_samples=num_context_samples,
192
+ options=options,
193
+ )
194
+
195
+ # Resolved once, and against the profile this request is for, because
196
+ # whether it is asked as several questions is a property of the query
197
+ # and the profile answers what the query is allowed to be.
198
+ asked = self._resolve(RELATIONAL if relational else TABULAR)
199
+ rewritten = parts_of(
200
+ asked,
201
+ self.text,
202
+ list(candidates) if candidates is not None else None,
203
+ options,
204
+ self.schema,
205
+ )
206
+ if task is not None and (relational or rewritten is not None):
207
+ why = (
208
+ 'a decomposed question'
209
+ if rewritten is not None
210
+ else 'a relational query'
211
+ )
212
+ raise StructuredDataError(
213
+ f'task={task!r} settles what a single-table question is '
214
+ f'asking, which is the only route that has to decide. '
215
+ f'{why.capitalize()} resolves to its own kind, so state it in '
216
+ f'the query rather than beside it',
217
+ code='INVALID_REQUEST',
218
+ details={'task': repr(task)},
219
+ )
220
+
221
+ if rewritten is not None:
222
+ return self._in_parts(
223
+ rewritten,
224
+ indices=indices,
225
+ predict=predict,
226
+ num_context_samples=num_context_samples,
227
+ model=model,
228
+ **options,
229
+ )
230
+
231
+ if predict is not None:
232
+ return self._tabular_materializer.table(
233
+ query=asked,
234
+ predict=predict,
235
+ task=task,
236
+ )
237
+
238
+ assert indices is not None
239
+ if not relational:
240
+ return self._tabular_materializer.indices(
241
+ query=asked,
242
+ indices=indices,
243
+ num_context_samples=num_context_samples,
244
+ task=task,
245
+ )
246
+
247
+ return self._relational_materializer.materialize(
248
+ query=asked,
249
+ indices=indices,
250
+ num_context_samples=num_context_samples,
251
+ options=options,
252
+ )
253
+
254
+ @staticmethod
255
+ def _validate_materialize_args(
256
+ *,
257
+ relational: bool,
258
+ model_id: str | None,
259
+ len_schema: int,
260
+ relationships: int,
261
+ indices: Sequence[Any] | None,
262
+ predict: pd.DataFrame | None,
263
+ num_context_samples: int | None,
264
+ options: dict[str, Any] | None,
265
+ ) -> None:
266
+ r"""Refuse a call that cannot be gathered, in an order that advises.
267
+
268
+ Which rows to score is a property of the call rather than of the route,
269
+ and naming a model resolves neither, so both are settled before
270
+ anything below can suggest one. The route comes next, because a model
271
+ that cannot read this schema at all explains every refusal after it.
272
+
273
+ Each message names a way out that works, which sometimes depends on the
274
+ schema rather than only on the call. Gathering options are honoured by
275
+ the relational model, which scores rows the tables already hold, so
276
+ naming that model alone would land the caller on the next refusal and
277
+ the message names ``indices`` too. A supplied frame can be gathered
278
+ tabularly only where the schema has no relationship to lose, so where
279
+ it has one the message says to drop it rather than to name a model that
280
+ would be refused for it.
281
+ """
282
+ if indices is not None and predict is not None:
283
+ raise StructuredDataError(
284
+ 'pass either indices=[...] to score rows the tables already '
285
+ 'contain, or predict=<frame> to score rows they do not, not both',
286
+ code='INVALID_REQUEST',
287
+ )
288
+
289
+ if indices is None and predict is None:
290
+ raise StructuredDataError(
291
+ 'materialize() needs the rows to score: pass indices=[...] '
292
+ 'naming the entities. There is no all-rows default, because '
293
+ 'it would silently score an entire table',
294
+ code='INVALID_REQUEST',
295
+ )
296
+
297
+ if not relational and len_schema != 1:
298
+ raise StructuredDataError(
299
+ f'{model_id} answers from a single table, and this schema has '
300
+ f'{len_schema}. Pass model=KumoRelational() to gather them as '
301
+ f'a graph',
302
+ code='INVALID_REQUEST',
303
+ )
304
+
305
+ if not relational and relationships:
306
+ raise StructuredDataError(
307
+ f'{model_id} reads no relationships, and this schema declares '
308
+ f'{relationships}. Pass model=KumoRelational() to gather it as '
309
+ f'a graph, or drop the relationship to ask about the table on '
310
+ f'its own',
311
+ code='INVALID_REQUEST',
312
+ )
313
+
314
+ if not relational and options:
315
+ raise StructuredDataError(
316
+ f'gathering options apply only to relational materialization: '
317
+ f'{sorted(options)}. Pass indices=[...] with '
318
+ f'model=KumoRelational() to gather this schema as a graph',
319
+ code='INVALID_REQUEST',
320
+ )
321
+
322
+ if predict is not None:
323
+ if len_schema != 1:
324
+ raise StructuredDataError(
325
+ f'predict=<frame> requires a single-table schema; '
326
+ f'got {len_schema} tables. For a relational schema, use '
327
+ f'indices=[...] to select entities already in the tables',
328
+ code='INVALID_REQUEST',
329
+ )
330
+ if relational:
331
+ instead = (
332
+ 'drop the relationship to gather this table for the '
333
+ 'tabular model'
334
+ if relationships
335
+ else 'gather this request for the tabular model'
336
+ )
337
+ raise StructuredDataError(
338
+ 'predict=<frame> is the single-table form, and '
339
+ f'{model_id or "a relational gather"} scores rows the '
340
+ f'tables already contain. Pass indices=[...] naming them, '
341
+ f'or {instead}',
342
+ code='INVALID_REQUEST',
343
+ )
344
+ if num_context_samples is not None:
345
+ raise StructuredDataError(
346
+ 'num_context_samples cannot be used with '
347
+ 'predict=<frame>: the schema table is the context. To '
348
+ 'choose the context, build the schema from the rows you '
349
+ 'want',
350
+ code='INVALID_REQUEST',
351
+ )
352
+
353
+ if indices is not None:
354
+ if not isinstance(indices, Sequence) or isinstance(
355
+ indices, (str, bytes)
356
+ ):
357
+ raise StructuredDataError(
358
+ f'indices must be a sequence of entity keys, got '
359
+ f'{type(indices).__name__}',
360
+ code='INVALID_REQUEST',
361
+ )
362
+ unusable = [value for value in indices if not _is_hashable(value)]
363
+ if unusable:
364
+ kinds = sorted({type(v).__name__ for v in unusable})
365
+ raise StructuredDataError(
366
+ f'indices must be entity keys, and {len(unusable)} of the '
367
+ f'{len(indices)} given cannot be one: {kinds}. A key is '
368
+ f'looked up by value, so it has to be hashable. This is '
369
+ f'what a row of json.loads(...) or '
370
+ f'frame.to_dict("records") gives you; pass the key field '
371
+ f'of each instead, e.g. [row[{"<key>"!r}] for row in '
372
+ f'rows].',
373
+ code='INVALID_REQUEST',
374
+ )
375
+
376
+ if num_context_samples is not None:
377
+ if isinstance(num_context_samples, bool) or not isinstance(
378
+ num_context_samples, Integral
379
+ ):
380
+ raise StructuredDataError(
381
+ 'num_context_samples must be a whole number of labelled '
382
+ f'examples, got {type(num_context_samples).__name__} '
383
+ f'{num_context_samples!r}',
384
+ code='INVALID_REQUEST',
385
+ )
386
+ if num_context_samples < 1:
387
+ raise StructuredDataError(
388
+ 'num_context_samples must be at least 1; a model predicts '
389
+ f'from labelled examples, and {num_context_samples} '
390
+ f'leaves it none',
391
+ code='INVALID_REQUEST',
392
+ )
393
+
394
+ def _in_parts(self, rewritten: Decomposition, **how: Any) -> Request:
395
+ r"""Gather the questions this one is asked as, and keep them together.
396
+
397
+ Each is gathered like any other query, because each needs the context
398
+ its own labels come from: what was true in the second window is not
399
+ what was true in the first, and whether an entity reached one
400
+ candidate is not whether it reached another.
401
+
402
+ A part is one call, which is what makes the fan-out countable, so a
403
+ question over more entities than one call scores is refused here
404
+ rather than gathered as parts that each batch.
405
+ """
406
+ import copy
407
+
408
+ how = {name: value for name, value in how.items() if value is not None}
409
+ asked = 0 if how.get('indices') is None else len(how['indices'])
410
+ refuse_a_part_that_would_take_several_calls(
411
+ rewritten, asked, rows_one_call_scores(rows_per_call_asked_for(how))
412
+ )
413
+ refuse_more_entities_than_fit_in_memory(asked, len(rewritten.parts))
414
+ # How many candidates to keep belongs to the whole ranking. Each part
415
+ # is one yes-or-no question, and a task that ranks nothing is refused
416
+ # by the endpoint for carrying an option it cannot act on.
417
+ how.pop('top_k', None)
418
+ gathered = []
419
+ for text, step in rewritten.parts:
420
+ try:
421
+ gathered.append(
422
+ PredictiveQuery(text, self.schema).materialize(**how)
423
+ )
424
+ except MissingExtraError:
425
+ raise
426
+ except StructuredDataError as error:
427
+ raise about_the_part(error, rewritten, step) from error
428
+ # A copy, because the carrier cannot be one of the things it carries:
429
+ # asking it would ask the first part, which would ask the first part.
430
+ whole = copy.copy(gathered[0])
431
+ whole._parts = tuple(gathered)
432
+ whole._decomposition = rewritten
433
+ whole._asked = self.text
434
+ return whole
435
+
436
+ def _resolve(self, profile: Profile) -> ValidatedPredictiveQuery:
437
+ r"""Parse and validate the query."""
438
+ from predictive_query import validate
439
+
440
+ from sdm_client.schema import to_schema_definition
441
+
442
+ resolved, response = validate(
443
+ self.text, to_schema_definition(self.schema), profile
444
+ )
445
+ if resolved is None or not response.ok:
446
+ reasons = (
447
+ '; '.join(
448
+ _without_the_validator_trace(error.message)
449
+ for error in response.errors
450
+ )
451
+ or 'the query did not resolve'
452
+ )
453
+ raise StructuredDataError(
454
+ f'{self.text!r} is not a query {profile.capabilities.model} '
455
+ f'can answer: {_without_a_remedy_this_caller_cannot_run(reasons)}'
456
+ f'{self._why_the_target_is_text(reasons, profile)}'
457
+ f'{self._how_to_resolve_an_ambiguous_link(reasons)}',
458
+ code='INVALID_REQUEST',
459
+ )
460
+ return resolved
461
+
462
+ def _how_to_resolve_an_ambiguous_link(self, reasons: str) -> str:
463
+ r"""Say how to resolve an ambiguous link, in the words of this object.
464
+
465
+ The language advises in its own vocabulary: `schema.unlink(...)`,
466
+ which is a call on its `SchemaDefinition` and not on the `Schema` a
467
+ caller holds. So the one instruction that would fix their query names
468
+ something they cannot do.
469
+
470
+ The reasons are read only to recognise the failure. What the competing
471
+ columns are is taken from the schema, which knows, rather than parsed
472
+ back out of a sentence that was written to be read.
473
+ """
474
+ if 'is not unique' not in reasons:
475
+ return ''
476
+ ways: dict[tuple[str, str], list[str]] = {}
477
+ for link in self.schema.relationships:
478
+ pair = (link.source_table, link.target_table)
479
+ ways.setdefault(pair, []).append(link.source_column)
480
+ ambiguous = {
481
+ pair: sorted(columns)
482
+ for pair, columns in ways.items()
483
+ if len(columns) > 1
484
+ }
485
+ if not ambiguous:
486
+ return ''
487
+ # Which pair the query crossed is in the refusal and nowhere else, so
488
+ # the tables are read from it, but only to choose among pairs the
489
+ # schema already knows about. Advising the first pair found sent a
490
+ # caller to edit a relationship their query never touched.
491
+ crossed = [
492
+ pair
493
+ for pair in ambiguous
494
+ if f'table {pair[0]} ' in reasons and f'table {pair[1]} ' in reasons
495
+ ]
496
+ if len(ambiguous) > 1 and not crossed:
497
+ return ''
498
+ (source, target), columns = sorted(
499
+ (pair, ambiguous[pair]) for pair in (crossed or ambiguous)
500
+ )[0]
501
+ kept, *rest = columns
502
+ return (
503
+ f' In this schema {source!r} reaches {target!r} through '
504
+ f'{", ".join(repr(name) for name in columns)}; to keep {kept!r}, '
505
+ f'schema.relationships = [link for link in schema.relationships '
506
+ f'if not (link.source_table == {source!r} and link.target_table '
507
+ f'== {target!r} and link.source_column in {rest!r})].'
508
+ )
509
+
510
+ def _why_the_target_is_text(self, reasons: str, profile: Profile) -> str:
511
+ r"""Say why a target is `text`, when that is what refused the query.
512
+
513
+ Inference types a string column by how many distinct values it holds,
514
+ and past sixty-four it is `text` rather than `categorical`. That is a
515
+ reasonable rule for a feature and a poor one for a target: an ordinary
516
+ multiclass label with seventy classes is refused as unpredictable, and
517
+ the refusal names a semantic type the caller never chose. Worse, it
518
+ depends on how many rows were handed to `from_data`, so the same query
519
+ answers on a sample and stops answering on the full table.
520
+
521
+ Setting the meaning by hand fixes it, verified at seventy classes and
522
+ at two hundred, so the refusal now says so.
523
+
524
+ The column named is the target rather than whichever text column comes
525
+ first. A schema with a free-text feature and a many-class label has
526
+ two, and naming the wrong one offers a remedy that changes nothing
527
+ about the refusal.
528
+ """
529
+ if 'semantic type text' not in reasons:
530
+ return ''
531
+ named = self._target_named_by_the_query()
532
+ if named is None:
533
+ return ''
534
+ table, name = named
535
+ column = self.schema[table].columns.get(name)
536
+ if column is None or column.stype != 'text' or column.stype_declared:
537
+ return ''
538
+ frame = getattr(self.schema[table], 'data', None)
539
+ distinct = frame[name].nunique() if frame is not None else None
540
+ holds = (
541
+ f'holds {distinct:,} distinct values'
542
+ if distinct is not None
543
+ else 'was read as prose'
544
+ )
545
+ said = (
546
+ f'. {table}.{name} {holds}, and a '
547
+ f'string column with more than {_MAX_CATEGORICAL} is typed text '
548
+ f'rather than categorical. That is fair for a feature and a poor '
549
+ f'one for a label'
550
+ )
551
+ ceiling = (
552
+ RELATIONAL_LIMITS['classes_max'] if profile == RELATIONAL else None
553
+ )
554
+ if ceiling is not None and distinct is not None and distinct > ceiling:
555
+ return (
556
+ f'{said}. Typing it categorical would not help here, though: '
557
+ f'{profile.capabilities.model} classifies at most {ceiling} '
558
+ f'classes and this column holds {distinct:,}, so the send '
559
+ f'would be refused. Predict a column with fewer classes, or '
560
+ f'group these into that many first'
561
+ )
562
+ return (
563
+ f'{said}. If it is a label, say so with '
564
+ f"schema[{table!r}][{name!r}].stype = 'categorical'"
565
+ )
566
+
567
+ def _target_named_by_the_query(self) -> tuple[str, str] | None:
568
+ r"""The table and column the query predicts, read from its text.
569
+
570
+ Read from the text rather than from a resolved query, because this runs
571
+ precisely when resolution failed.
572
+ """
573
+ match = _PREDICTED_COLUMN.search(self.text)
574
+ if match is None:
575
+ return None
576
+ table, name = match.group(1), match.group(2)
577
+ if table not in self.schema:
578
+ return None
579
+ return table, name
580
+
581
+ def __repr__(self) -> str:
582
+ return f'PredictiveQuery({self.text!r}, {self.schema!r})'
583
+
584
+
585
+ #: `PREDICT <table>.<column>` is the plain column target, which is the only
586
+ #: shape a semantic type can be refused for.
587
+ _PREDICTED_COLUMN = re.compile(
588
+ r'PREDICT\s+([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s', re.IGNORECASE
589
+ )
590
+
591
+ #: The English sentence inside a pydantic validation error, without the
592
+ #: `[type=..., input_value=...]` tail the library appends to it.
593
+ _VALIDATOR_SENTENCE = re.compile(r'Value error, (.+?)(?= \[type=)', re.DOTALL)
594
+
595
+
596
+ _A_REMEDY_IN_THE_LANGUAGE_S_OWN_WORDS = re.compile(
597
+ r'\s*(?:Keep the one this query means and drop the rest, for example:'
598
+ r'|You can drop the extra foreign keys? with:)'
599
+ r'[^.]*?(?:schema|graph)\.unlink\([^)]*\)'
600
+ r'(?:\s*;\s*(?:schema|graph)\.unlink\([^)]*\))*\.?'
601
+ )
602
+
603
+
604
+ def _without_a_remedy_this_caller_cannot_run(reasons: str) -> str:
605
+ r"""The refusal without the repair written in the language's vocabulary.
606
+
607
+ The query language advises `schema.unlink(...)`, a call on its own
608
+ `SchemaDefinition` and not on the `Schema` a caller holds, and it chooses
609
+ which link to keep by position. This object restates both in terms the
610
+ caller can run, and two remedies that disagree about which link to keep
611
+ are worse than one: followed in order they keep the wrong one. So the one
612
+ that cannot be run is removed rather than printed beside it.
613
+ """
614
+ return _A_REMEDY_IN_THE_LANGUAGE_S_OWN_WORDS.sub('', reasons)
615
+
616
+
617
+ def _without_the_validator_trace(message: str) -> str:
618
+ r"""The sentence a refusal is making, without the trace around it.
619
+
620
+ A few window errors, including zero-width and inverted windows, are raised
621
+ by
622
+ the grammar's own models rather than by its parser, so they arrive as a
623
+ pydantic trace: an error count, the internal type that raised, the repr of
624
+ the arguments it was given, and a link to pydantic's documentation. The
625
+ English inside is as actionable as the parser's own row-and-column
626
+ messages; nothing around it is, and none of it is this package's to show.
627
+
628
+ A message written any other way is left alone, so a validator that stops
629
+ wrapping its errors this way loses the scaffolding rather than the text.
630
+ """
631
+ said = _VALIDATOR_SENTENCE.findall(message)
632
+ return '; '.join(part.strip() for part in said) if said else message
633
+
634
+
635
+ def _is_hashable(value: Any) -> bool:
636
+ r"""Whether this value can be a dict key, asked by trying.
637
+
638
+ `isinstance(value, Hashable)` is structural: a class that defines
639
+ `__hash__` and raises from it passes, and the `TypeError` surfaces later
640
+ from inside pandas, which is the shape of failure this check exists to
641
+ prevent.
642
+ """
643
+ try:
644
+ hash(value)
645
+ except TypeError:
646
+ return False
647
+ return True