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,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)