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
sdm_client/core/dates.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Reading a date out of text, only where the text says one thing.
|
|
6
|
+
|
|
7
|
+
Inference does not parse strings for dates, because a reader loose enough for
|
|
8
|
+
a warehouse `DATE` column stored as `STRING` also reads part codes as dates and
|
|
9
|
+
fails much later on a value it should never have touched. But a caller may name
|
|
10
|
+
a string column as a table's time column, and then it has to be read. This
|
|
11
|
+
is where the reading is done, and where it refuses rather than guesses.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
_ZONE = r'(?:Z|[+-]\d{2}(?::?\d{2})?|[A-Za-z][A-Za-z0-9_+/-]*)'
|
|
24
|
+
|
|
25
|
+
#: A date that leads with its year, which is one of the two ways a column can
|
|
26
|
+
#: say what it holds. `2020-01-02` says it outright; `02/01/2020` is the 2nd of
|
|
27
|
+
#: January to half the world and the 1st of February to the other half, and is
|
|
28
|
+
#: decided, if at all, by `day_comes_first`.
|
|
29
|
+
YEAR_FIRST = re.compile(
|
|
30
|
+
r'\d{4}[-/]\d{2}[-/]\d{2}'
|
|
31
|
+
r'([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?'
|
|
32
|
+
r'( ?' + _ZONE + r')?)?'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: Enough rows to be sure the whole column has one shape, few enough that
|
|
36
|
+
#: inference stays cheap on a table of millions.
|
|
37
|
+
SAMPLE = 1000
|
|
38
|
+
|
|
39
|
+
#: `4711-01-02` is a part number and a valid date, and pandas reads it as the
|
|
40
|
+
#: year 4711 without complaint. Nothing in the value distinguishes the two, so
|
|
41
|
+
#: the window does: a column of transactional dates lands inside it, and a code
|
|
42
|
+
#: that happens to be date-shaped usually does not.
|
|
43
|
+
PLAUSIBLE_YEARS = (1900, 2100)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: `dd/mm/yyyy` or `mm/dd/yyyy`, whichever it turns out to be, with or without
|
|
47
|
+
#: a time and a zone after it. A warehouse hands back all of them in the
|
|
48
|
+
#: same spellings `YEAR_FIRST` takes, because a column is not written in two
|
|
49
|
+
#: shapes just because this client reads a zone on one of them.
|
|
50
|
+
_TWO_PART_PATTERN = (
|
|
51
|
+
r'^(\d{1,2})[-/](\d{1,2})[-/]\d{2,4}'
|
|
52
|
+
r'(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?( ?' + _ZONE + r')?)?$'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
DAY_OR_MONTH_FIRST = re.compile(_TWO_PART_PATTERN)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shapes_written(values: pd.Series) -> dict[str, str]:
|
|
60
|
+
r"""One example of each shape the column is written in.
|
|
61
|
+
|
|
62
|
+
A column written two ways has no single reading: the call that reads its
|
|
63
|
+
slash-separated rows re-dates its year-first ones.
|
|
64
|
+
|
|
65
|
+
Every value is read, for the reason `day_comes_first` reads every value:
|
|
66
|
+
the row that makes a column two shapes can be the last one.
|
|
67
|
+
"""
|
|
68
|
+
text = values[[isinstance(value, str) for value in values]]
|
|
69
|
+
if text.empty:
|
|
70
|
+
return {}
|
|
71
|
+
text = text.astype(str).str.strip()
|
|
72
|
+
leads_with_its_year = text.str.fullmatch(YEAR_FIRST.pattern, na=False)
|
|
73
|
+
rest = text[~leads_with_its_year]
|
|
74
|
+
shaped = {
|
|
75
|
+
'year-first': text[leads_with_its_year],
|
|
76
|
+
'day-or-month-first': rest[rest.str.match(_TWO_PART_PATTERN, na=False)],
|
|
77
|
+
}
|
|
78
|
+
found: dict[str, str] = {}
|
|
79
|
+
for name, values in shaped.items():
|
|
80
|
+
if values.empty:
|
|
81
|
+
continue
|
|
82
|
+
readable = values[reads_as_a_date(values)]
|
|
83
|
+
if not readable.empty:
|
|
84
|
+
found[name] = readable.iloc[0]
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reads_as_a_date(values: pd.Series) -> pd.Series:
|
|
89
|
+
r"""Which of ``values`` are a date under some reading of them.
|
|
90
|
+
|
|
91
|
+
The one answer to that question. It was asked in four places and answered
|
|
92
|
+
four ways, and three of them could not answer at all for a column of mixed
|
|
93
|
+
offsets, or counted a date the column reads as one it does not.
|
|
94
|
+
|
|
95
|
+
A shape counts only where it holds dates. `99/99/2020` is shaped like a
|
|
96
|
+
slash date and is not one, and `2021-13-45` is shaped like a year-first
|
|
97
|
+
one; reporting a column holding either as written two ways sends its owner
|
|
98
|
+
to reconcile two formats when what they have is one bad value.
|
|
99
|
+
|
|
100
|
+
Asked of each value rather than of the column: one inferred format does
|
|
101
|
+
not cover a column zoned on some rows and bare on others, and whether a
|
|
102
|
+
value is a date is not a question about its neighbours. Read to a common
|
|
103
|
+
zone, and a reading that will not run at all counts as reading nothing.
|
|
104
|
+
This is asked to find out whether a column reads, so it is the one place
|
|
105
|
+
that must not fail to answer.
|
|
106
|
+
"""
|
|
107
|
+
read = pd.Series(False, index=values.index)
|
|
108
|
+
with warnings.catch_warnings():
|
|
109
|
+
warnings.simplefilter('ignore')
|
|
110
|
+
for dayfirst in (True, False):
|
|
111
|
+
for mixed in ({}, {'format': 'mixed'}):
|
|
112
|
+
try:
|
|
113
|
+
read |= pd.to_datetime(
|
|
114
|
+
values,
|
|
115
|
+
errors='coerce',
|
|
116
|
+
dayfirst=dayfirst,
|
|
117
|
+
utc=True,
|
|
118
|
+
**mixed,
|
|
119
|
+
).notna()
|
|
120
|
+
except (ValueError, TypeError, OverflowError):
|
|
121
|
+
continue
|
|
122
|
+
return read
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def day_comes_first(values: pd.Series, limit: int | None = None) -> bool | None:
|
|
126
|
+
r"""Which way round a slash-separated column reads, if it says.
|
|
127
|
+
|
|
128
|
+
`02/01/2024` is two dates and the column cannot tell you which. But a
|
|
129
|
+
column holding `15/03/2024` has said: no month is 15, so the first
|
|
130
|
+
component is the day, and every other row reads the same way. The same
|
|
131
|
+
argument the other way round for `03/15/2024`.
|
|
132
|
+
|
|
133
|
+
The question is not whether a single value is ambiguous because most are,
|
|
134
|
+
but whether anything in the column decides it. `None` means nothing did,
|
|
135
|
+
which is also the answer when the column contradicts itself.
|
|
136
|
+
|
|
137
|
+
Every value is read, rather than the sample the shape test takes. One
|
|
138
|
+
deciding value is enough and it can be anywhere: a column of a thousand
|
|
139
|
+
`01/02/2024` followed by one `03/15/2024` is decided by the last of them,
|
|
140
|
+
and sampling the front of it would refuse a column that answers. Read as
|
|
141
|
+
two columns of numbers rather than a loop, so the whole of a large one
|
|
142
|
+
costs about what the sample did.
|
|
143
|
+
"""
|
|
144
|
+
read = values.head(limit) if limit else values
|
|
145
|
+
if read.empty:
|
|
146
|
+
return None
|
|
147
|
+
parts = read.astype(str).str.strip().str.extract(_TWO_PART_PATTERN)
|
|
148
|
+
if parts.empty:
|
|
149
|
+
return None
|
|
150
|
+
one = pd.to_numeric(parts[0], errors='coerce')
|
|
151
|
+
two = pd.to_numeric(parts[1], errors='coerce')
|
|
152
|
+
first_over = bool((one > 12).any())
|
|
153
|
+
second_over = bool((two > 12).any())
|
|
154
|
+
if first_over == second_over:
|
|
155
|
+
return None
|
|
156
|
+
return first_over
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def looks_unambiguous(values: pd.Series, limit: int = SAMPLE) -> bool:
|
|
160
|
+
r"""Whether the column says what its dates are.
|
|
161
|
+
|
|
162
|
+
Year-first says it outright, and the shape of a sample answers that: the
|
|
163
|
+
column has one shape throughout. A slash-separated column says it when one
|
|
164
|
+
of its own values rules a reading out, which is the ordinary case for any
|
|
165
|
+
column spanning more than twelve days. That question is asked of the
|
|
166
|
+
whole column, because the value that answers it can be anywhere.
|
|
167
|
+
|
|
168
|
+
Matches whole rather than by prefix: a part code can open with something
|
|
169
|
+
date-shaped and continue.
|
|
170
|
+
"""
|
|
171
|
+
read = values.head(limit) if limit else values
|
|
172
|
+
if read.empty:
|
|
173
|
+
return False
|
|
174
|
+
if all(
|
|
175
|
+
isinstance(value, str) and YEAR_FIRST.fullmatch(value.strip())
|
|
176
|
+
for value in read
|
|
177
|
+
):
|
|
178
|
+
return True
|
|
179
|
+
return day_comes_first(values) is not None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Conversion:
|
|
184
|
+
r"""The call that reads one column, as arguments rather than as text.
|
|
185
|
+
|
|
186
|
+
`pd.to_datetime` takes these; a message shows them. Carrying the decision
|
|
187
|
+
as a value keeps those two from drifting, and keeps the reading that was
|
|
188
|
+
checked as the reading that is applied. Deciding it again at the point
|
|
189
|
+
of use is how the two come to disagree.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
utc: bool = False
|
|
193
|
+
dayfirst: bool | None = None
|
|
194
|
+
mixed: bool = False
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def arguments(self) -> dict[str, Any]:
|
|
198
|
+
r"""What to pass `pd.to_datetime`."""
|
|
199
|
+
given: dict[str, Any] = {}
|
|
200
|
+
if self.utc:
|
|
201
|
+
given['utc'] = True
|
|
202
|
+
if self.dayfirst is not None:
|
|
203
|
+
given['dayfirst'] = self.dayfirst
|
|
204
|
+
if self.mixed:
|
|
205
|
+
given['format'] = 'mixed'
|
|
206
|
+
return given
|
|
207
|
+
|
|
208
|
+
def __str__(self) -> str:
|
|
209
|
+
r"""Write the call for a message that tells someone to make it."""
|
|
210
|
+
return ''.join(
|
|
211
|
+
f', {name}={value!r}' for name, value in self.arguments.items()
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def conversion_that_works(
|
|
216
|
+
values: pd.Series,
|
|
217
|
+
*,
|
|
218
|
+
only_plausible_years: bool = True,
|
|
219
|
+
per_row: bool = False,
|
|
220
|
+
) -> Conversion | None:
|
|
221
|
+
r"""The arguments `pd.to_datetime` needs for this column, or nothing.
|
|
222
|
+
|
|
223
|
+
Run rather than reasoned about. A column of impossible dates coerces to
|
|
224
|
+
NaT and one of mixed UTC offsets raises instead of coercing, and both are
|
|
225
|
+
date-shaped enough to reach here. A column that converts to years no
|
|
226
|
+
warehouse holds is a code that looks like a date, and telling its owner to
|
|
227
|
+
convert it would turn part numbers into timestamps.
|
|
228
|
+
|
|
229
|
+
That window is for deciding whether an undeclared column is a date at all.
|
|
230
|
+
A caller who has named the column as their time column has answered that,
|
|
231
|
+
and `only_plausible_years=False` takes the answer: a table of nineteenth
|
|
232
|
+
century records is a real table, and refusing it for the year it holds
|
|
233
|
+
would be this package overruling what it was told.
|
|
234
|
+
|
|
235
|
+
`per_row` adds `format='mixed'`, after the readings that do not need it. A
|
|
236
|
+
warehouse writes a zone on the rows that have one, so a column can be
|
|
237
|
+
year-first throughout and still need a format per value. It is asked for
|
|
238
|
+
only where the column has been found to be one shape: reading each value
|
|
239
|
+
on its own terms will also read a column written two ways, by re-dating
|
|
240
|
+
the half that is not the one it settles on.
|
|
241
|
+
"""
|
|
242
|
+
first, last = PLAUSIBLE_YEARS
|
|
243
|
+
order = day_comes_first(values)
|
|
244
|
+
for mixed in [False, True] if per_row else [False]:
|
|
245
|
+
for utc in (False, True):
|
|
246
|
+
candidate = Conversion(utc=utc, dayfirst=order, mixed=mixed)
|
|
247
|
+
try:
|
|
248
|
+
with warnings.catch_warnings():
|
|
249
|
+
warnings.simplefilter('ignore')
|
|
250
|
+
converted = pd.to_datetime(
|
|
251
|
+
values, errors='coerce', **candidate.arguments
|
|
252
|
+
)
|
|
253
|
+
except (ValueError, TypeError, OverflowError):
|
|
254
|
+
continue
|
|
255
|
+
# pandas 2 answers mixed offsets without `utc=True` with a column
|
|
256
|
+
# of objects, and a warning, where pandas 3 raises. Neither is a
|
|
257
|
+
# reading of the column, so both go on to the next candidate.
|
|
258
|
+
if not pd.api.types.is_datetime64_any_dtype(converted):
|
|
259
|
+
continue
|
|
260
|
+
if converted.isna().any():
|
|
261
|
+
continue
|
|
262
|
+
if not only_plausible_years:
|
|
263
|
+
return candidate
|
|
264
|
+
years = converted.dt.year
|
|
265
|
+
if first <= years.min() and years.max() <= last:
|
|
266
|
+
return candidate
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read(values: pd.Series, conversion: Conversion) -> pd.Series:
|
|
271
|
+
r"""``values`` as datetimes, by the call `conversion_that_works` found.
|
|
272
|
+
|
|
273
|
+
The conversion is carried rather than worked out again, so the reading
|
|
274
|
+
that was checked against the whole column is the reading applied to it.
|
|
275
|
+
"""
|
|
276
|
+
with warnings.catch_warnings():
|
|
277
|
+
warnings.simplefilter('ignore')
|
|
278
|
+
return pd.to_datetime(values, **conversion.arguments)
|