lorebase-core 0.2.3__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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Column statistics and type detection.
|
|
2
|
+
|
|
3
|
+
Each non-null value is tested against the candidate types in precedence order: ``boolean``,
|
|
4
|
+
``integer``, ``decimal``, ``float``, ``date``, ``timestamp``, ``json``. The first type that at
|
|
5
|
+
least ``1 - type_tolerance`` of the values satisfy wins. Otherwise the column is textual:
|
|
6
|
+
``enum`` when it has few distinct short values, ``text`` when values are long or prose under a
|
|
7
|
+
text-like name, ``string`` (with ``max_length`` rounded up to a bucket and, for code-like
|
|
8
|
+
values, a ``pattern``) otherwise. A declared catalog type always wins over detection.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from decimal import Decimal, InvalidOperation
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from itertools import islice
|
|
22
|
+
|
|
23
|
+
from lorebase_core.infer.names import matches, normalize
|
|
24
|
+
from lorebase_core.infer.options import InferenceOptions
|
|
25
|
+
from lorebase_core.infer.profile import ColumnProfile
|
|
26
|
+
from lorebase_core.types import (
|
|
27
|
+
INT64_MAX,
|
|
28
|
+
INT64_MIN,
|
|
29
|
+
AttributeType,
|
|
30
|
+
Constraints,
|
|
31
|
+
EncodingError,
|
|
32
|
+
TypeSpec,
|
|
33
|
+
encode_value,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_INTEGER_TEXT = re.compile(r"[+-]?[0-9]+")
|
|
37
|
+
_DECIMAL_TEXT = re.compile(r"[+-]?([0-9]+)(?:\.([0-9]+))?")
|
|
38
|
+
_FLOAT_TEXT = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")
|
|
39
|
+
_ULID = re.compile(r"[0-7][0-9A-HJKMNP-TV-Z]{25}", re.IGNORECASE)
|
|
40
|
+
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
|
|
41
|
+
_REGEX_SPECIALS = frozenset(".^$*+?()[]{}|\\/")
|
|
42
|
+
_TIMESTAMP = TypeSpec.of(AttributeType.TIMESTAMP)
|
|
43
|
+
|
|
44
|
+
#: Textual types: what a column is when no other type fits.
|
|
45
|
+
TEXT_FAMILY = frozenset({AttributeType.STRING, AttributeType.TEXT, AttributeType.ENUM})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Family(StrEnum):
|
|
49
|
+
"""Value families that can reference each other (foreign key to key)."""
|
|
50
|
+
|
|
51
|
+
INTEGER = "integer"
|
|
52
|
+
TEXT = "text"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class MixedTypes:
|
|
57
|
+
"""A column whose values mostly, not all, parse as ``dominant``."""
|
|
58
|
+
|
|
59
|
+
dominant: AttributeType
|
|
60
|
+
share: float
|
|
61
|
+
outliers: tuple[str, ...]
|
|
62
|
+
accepted: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ColumnAnalysis:
|
|
67
|
+
"""What inference knows about one column."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
snake: str
|
|
71
|
+
index: int
|
|
72
|
+
count: int
|
|
73
|
+
nulls: int
|
|
74
|
+
#: Normalized text of each value read, ``None`` for nulls (row aligned).
|
|
75
|
+
row_keys: tuple[str | None, ...]
|
|
76
|
+
distinct_keys: frozenset[str]
|
|
77
|
+
type: TypeSpec
|
|
78
|
+
constraints: Constraints
|
|
79
|
+
required: bool
|
|
80
|
+
unique: bool
|
|
81
|
+
declared_unique: bool
|
|
82
|
+
declared_type: bool
|
|
83
|
+
confidence: float
|
|
84
|
+
reasons: tuple[str, ...]
|
|
85
|
+
samples: tuple[str, ...]
|
|
86
|
+
generated_ids: bool
|
|
87
|
+
mixed: MixedTypes | None = None
|
|
88
|
+
comment: str | None = None
|
|
89
|
+
numeric_min: float | None = field(default=None)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def non_null(self) -> int:
|
|
93
|
+
return self.count - self.nulls
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def family(self) -> Family | None:
|
|
97
|
+
name = self.type.name
|
|
98
|
+
if name is AttributeType.INTEGER:
|
|
99
|
+
return Family.INTEGER
|
|
100
|
+
if name in (AttributeType.STRING, AttributeType.ENUM, AttributeType.URI):
|
|
101
|
+
return Family.TEXT
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def support(non_null: int, options: InferenceOptions) -> float:
|
|
106
|
+
"""Confidence factor for the amount of evidence: 0.5 with no values, 1 at
|
|
107
|
+
``min_support_values`` and above."""
|
|
108
|
+
return 0.5 + 0.5 * min(1.0, non_null / options.min_support_values)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Value readers -------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def text_of(value: object, options: InferenceOptions) -> str | None:
|
|
115
|
+
"""Text form of a value, or ``None`` for nulls and null tokens."""
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(value, str):
|
|
119
|
+
text = value.strip() if options.trim_text else value
|
|
120
|
+
return None if text in options.null_tokens else text
|
|
121
|
+
if isinstance(value, bool):
|
|
122
|
+
return "true" if value else "false"
|
|
123
|
+
if isinstance(value, float):
|
|
124
|
+
return None if math.isnan(value) else repr(value)
|
|
125
|
+
if isinstance(value, Decimal):
|
|
126
|
+
return format(value, "f")
|
|
127
|
+
if isinstance(value, datetime | date):
|
|
128
|
+
return value.isoformat()
|
|
129
|
+
if isinstance(value, dict | list):
|
|
130
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
131
|
+
return str(value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _leading_zero(digits: str, options: InferenceOptions) -> bool:
|
|
135
|
+
return options.leading_zero_integers_as_text and len(digits) > 1 and digits[0] == "0"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _as_bool(value: object, text: str, options: InferenceOptions) -> bool:
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return True
|
|
141
|
+
if not isinstance(value, str):
|
|
142
|
+
return False
|
|
143
|
+
lowered = text.lower()
|
|
144
|
+
return lowered in {t.lower() for t in options.true_tokens} or lowered in {
|
|
145
|
+
t.lower() for t in options.false_tokens
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _as_int(value: object, text: str, options: InferenceOptions) -> int | None:
|
|
150
|
+
if isinstance(value, bool):
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(value, int):
|
|
153
|
+
number = value
|
|
154
|
+
elif isinstance(value, Decimal):
|
|
155
|
+
if not value.is_finite() or value != value.to_integral_value():
|
|
156
|
+
return None
|
|
157
|
+
number = int(value)
|
|
158
|
+
elif isinstance(value, str):
|
|
159
|
+
if not _INTEGER_TEXT.fullmatch(text) or _leading_zero(text.lstrip("+-"), options):
|
|
160
|
+
return None
|
|
161
|
+
number = int(text)
|
|
162
|
+
else:
|
|
163
|
+
return None
|
|
164
|
+
return number if INT64_MIN <= number <= INT64_MAX else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _as_decimal(value: object, text: str, options: InferenceOptions) -> tuple[int, int] | None:
|
|
168
|
+
"""``(integer digits, scale)`` of an exact decimal number."""
|
|
169
|
+
if isinstance(value, bool | float):
|
|
170
|
+
return None
|
|
171
|
+
if isinstance(value, int):
|
|
172
|
+
return (len(str(abs(value))), 0)
|
|
173
|
+
if isinstance(value, Decimal):
|
|
174
|
+
if not value.is_finite():
|
|
175
|
+
return None
|
|
176
|
+
exponent = value.as_tuple().exponent
|
|
177
|
+
assert isinstance(exponent, int)
|
|
178
|
+
scale = max(0, -exponent)
|
|
179
|
+
integer_digits = len(format(abs(value).to_integral_value(rounding="ROUND_DOWN"), "f"))
|
|
180
|
+
return (integer_digits, scale)
|
|
181
|
+
if not isinstance(value, str):
|
|
182
|
+
return None
|
|
183
|
+
match = _DECIMAL_TEXT.fullmatch(text)
|
|
184
|
+
if match is None or _leading_zero(match[1], options):
|
|
185
|
+
return None
|
|
186
|
+
return (len(match[1].lstrip("0") or "0"), len(match[2] or ""))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _as_float(value: object, text: str, options: InferenceOptions) -> float | None:
|
|
190
|
+
if isinstance(value, bool):
|
|
191
|
+
return None
|
|
192
|
+
if isinstance(value, int | float | Decimal):
|
|
193
|
+
number = float(value)
|
|
194
|
+
elif isinstance(value, str):
|
|
195
|
+
if not _FLOAT_TEXT.fullmatch(text):
|
|
196
|
+
return None
|
|
197
|
+
integer_part = text.lstrip("+-").split(".")[0].split("e")[0].split("E")[0]
|
|
198
|
+
if _leading_zero(integer_part, options):
|
|
199
|
+
return None
|
|
200
|
+
number = float(text)
|
|
201
|
+
else:
|
|
202
|
+
return None
|
|
203
|
+
return number if math.isfinite(number) else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _as_date(value: object, text: str, options: InferenceOptions) -> bool:
|
|
207
|
+
if isinstance(value, datetime):
|
|
208
|
+
return False
|
|
209
|
+
if isinstance(value, date):
|
|
210
|
+
return True
|
|
211
|
+
if not isinstance(value, str):
|
|
212
|
+
return False
|
|
213
|
+
for fmt in options.date_formats:
|
|
214
|
+
try:
|
|
215
|
+
datetime.strptime(text, fmt)
|
|
216
|
+
except ValueError:
|
|
217
|
+
continue
|
|
218
|
+
return True
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _as_timestamp(value: object, text: str, options: InferenceOptions) -> tuple[bool, bool]:
|
|
223
|
+
"""``(is a timestamp, has no offset)``."""
|
|
224
|
+
if isinstance(value, datetime):
|
|
225
|
+
return (True, value.tzinfo is None)
|
|
226
|
+
if not isinstance(value, str):
|
|
227
|
+
return (False, False)
|
|
228
|
+
try:
|
|
229
|
+
encode_value(_TIMESTAMP, text)
|
|
230
|
+
except EncodingError:
|
|
231
|
+
pass
|
|
232
|
+
else:
|
|
233
|
+
return (True, False)
|
|
234
|
+
for fmt in options.timestamp_formats:
|
|
235
|
+
try:
|
|
236
|
+
parsed = datetime.strptime(text, fmt)
|
|
237
|
+
except ValueError:
|
|
238
|
+
continue
|
|
239
|
+
return (True, parsed.tzinfo is None)
|
|
240
|
+
return (False, False)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _as_json(value: object, text: str) -> bool:
|
|
244
|
+
if isinstance(value, dict | list):
|
|
245
|
+
return True
|
|
246
|
+
if not isinstance(value, str) or text[:1] not in ("{", "["):
|
|
247
|
+
return False
|
|
248
|
+
try:
|
|
249
|
+
return isinstance(json.loads(text), dict | list)
|
|
250
|
+
except ValueError:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Text shapes ---------------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _runs(text: str) -> list[tuple[str, str]]:
|
|
258
|
+
"""``S-1001`` -> ``[("A", "S"), ("-", "-"), ("9", "1001")]``."""
|
|
259
|
+
runs: list[tuple[str, str]] = []
|
|
260
|
+
for char in text:
|
|
261
|
+
if "A" <= char <= "Z":
|
|
262
|
+
cls = "A"
|
|
263
|
+
elif "a" <= char <= "z":
|
|
264
|
+
cls = "a"
|
|
265
|
+
elif "0" <= char <= "9":
|
|
266
|
+
cls = "9"
|
|
267
|
+
else:
|
|
268
|
+
cls = char
|
|
269
|
+
if runs and runs[-1][0] == cls and cls in ("A", "a", "9"):
|
|
270
|
+
runs[-1] = (cls, runs[-1][1] + char)
|
|
271
|
+
else:
|
|
272
|
+
runs.append((cls, char))
|
|
273
|
+
return runs
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _escape(text: str) -> str:
|
|
277
|
+
return "".join("\\" + c if c in _REGEX_SPECIALS else c for c in text)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def shape_pattern(values: Sequence[str]) -> str | None:
|
|
281
|
+
"""A pattern shared by code-like ``values`` (``^S-[0-9]{4,}$``, ``^[A-Z]{2}$``), or
|
|
282
|
+
``None`` when values differ in shape or are not code-like (no digits and not a short
|
|
283
|
+
upper-case code)."""
|
|
284
|
+
shapes = [_runs(v) for v in values]
|
|
285
|
+
classes = [cls for cls, _ in shapes[0]]
|
|
286
|
+
if not classes or any([cls for cls, _ in shape] != classes for shape in shapes[1:]):
|
|
287
|
+
return None
|
|
288
|
+
code_like = "9" in classes or (classes == ["A"] and max(len(v) for v in values) <= 4)
|
|
289
|
+
if not code_like or " " in classes:
|
|
290
|
+
return None
|
|
291
|
+
parts = []
|
|
292
|
+
for position, cls in enumerate(classes):
|
|
293
|
+
texts = [shape[position][1] for shape in shapes]
|
|
294
|
+
lengths = [len(t) for t in texts]
|
|
295
|
+
if cls not in ("A", "a", "9"):
|
|
296
|
+
parts.append(_escape(cls))
|
|
297
|
+
elif cls == "9":
|
|
298
|
+
parts.append(f"[0-9]{{{min(lengths)},}}")
|
|
299
|
+
elif len(set(texts)) == 1:
|
|
300
|
+
parts.append(_escape(texts[0]))
|
|
301
|
+
else:
|
|
302
|
+
letters = "[A-Z]" if cls == "A" else "[a-z]"
|
|
303
|
+
low, high = min(lengths), max(lengths)
|
|
304
|
+
parts.append(f"{letters}{{{low}}}" if low == high else f"{letters}{{{low},{high}}}")
|
|
305
|
+
return "^" + "".join(parts) + "$"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def generated_ids(texts: Iterable[str]) -> bool:
|
|
309
|
+
"""Whether every value is a ULID or a UUID."""
|
|
310
|
+
seen = False
|
|
311
|
+
for text in texts:
|
|
312
|
+
if not (_ULID.fullmatch(text) or _UUID.fullmatch(text)):
|
|
313
|
+
return False
|
|
314
|
+
seen = True
|
|
315
|
+
return seen
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Analysis ------------------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class _Tally:
|
|
323
|
+
"""Per-type counts over the non-null values of a column."""
|
|
324
|
+
|
|
325
|
+
boolean: int = 0
|
|
326
|
+
integer: int = 0
|
|
327
|
+
decimal: int = 0
|
|
328
|
+
floating: int = 0
|
|
329
|
+
date: int = 0
|
|
330
|
+
timestamp: int = 0
|
|
331
|
+
naive_timestamps: int = 0
|
|
332
|
+
json: int = 0
|
|
333
|
+
integer_digits: int = 1
|
|
334
|
+
scale: int = 0
|
|
335
|
+
negative: bool = False
|
|
336
|
+
numeric_min: float | None = None
|
|
337
|
+
failures: dict[AttributeType, list[str]] = field(default_factory=dict)
|
|
338
|
+
|
|
339
|
+
def fail(self, name: AttributeType, text: str) -> None:
|
|
340
|
+
self.failures.setdefault(name, []).append(text)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _tally(values: Sequence[tuple[object, str]], options: InferenceOptions) -> _Tally:
|
|
344
|
+
tally = _Tally()
|
|
345
|
+
for value, text in values:
|
|
346
|
+
if _as_bool(value, text, options):
|
|
347
|
+
tally.boolean += 1
|
|
348
|
+
else:
|
|
349
|
+
tally.fail(AttributeType.BOOLEAN, text)
|
|
350
|
+
if _as_int(value, text, options) is not None:
|
|
351
|
+
tally.integer += 1
|
|
352
|
+
else:
|
|
353
|
+
tally.fail(AttributeType.INTEGER, text)
|
|
354
|
+
digits = _as_decimal(value, text, options)
|
|
355
|
+
if digits is not None:
|
|
356
|
+
tally.decimal += 1
|
|
357
|
+
tally.integer_digits = max(tally.integer_digits, digits[0])
|
|
358
|
+
tally.scale = max(tally.scale, digits[1])
|
|
359
|
+
else:
|
|
360
|
+
tally.fail(AttributeType.DECIMAL, text)
|
|
361
|
+
number = _as_float(value, text, options)
|
|
362
|
+
if number is not None:
|
|
363
|
+
tally.floating += 1
|
|
364
|
+
tally.numeric_min = (
|
|
365
|
+
number if tally.numeric_min is None else min(tally.numeric_min, number)
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
tally.fail(AttributeType.FLOAT, text)
|
|
369
|
+
if _as_date(value, text, options):
|
|
370
|
+
tally.date += 1
|
|
371
|
+
else:
|
|
372
|
+
tally.fail(AttributeType.DATE, text)
|
|
373
|
+
is_timestamp, naive = _as_timestamp(value, text, options)
|
|
374
|
+
if is_timestamp:
|
|
375
|
+
tally.timestamp += 1
|
|
376
|
+
tally.naive_timestamps += naive
|
|
377
|
+
else:
|
|
378
|
+
tally.fail(AttributeType.TIMESTAMP, text)
|
|
379
|
+
if _as_json(value, text):
|
|
380
|
+
tally.json += 1
|
|
381
|
+
else:
|
|
382
|
+
tally.fail(AttributeType.JSON, text)
|
|
383
|
+
return tally
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _numbered(distinct: Sequence[str]) -> bool:
|
|
387
|
+
"""Whether the values are numbered identifiers of one shape (``analyst-1``, ``analyst-2``):
|
|
388
|
+
such columns are open-ended codes, not enums."""
|
|
389
|
+
if len(distinct) < 2:
|
|
390
|
+
return False
|
|
391
|
+
pattern = shape_pattern(distinct)
|
|
392
|
+
return pattern is not None and "[0-9]" in pattern
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _bucket(length: int, options: InferenceOptions) -> int | None:
|
|
396
|
+
return next((b for b in options.string_length_buckets if b >= length), None)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _textual(
|
|
400
|
+
snake: str, texts: Sequence[str], distinct: Sequence[str], options: InferenceOptions
|
|
401
|
+
) -> tuple[TypeSpec, Constraints, list[str]]:
|
|
402
|
+
"""Type, constraints and reasons of a column with no non-text type."""
|
|
403
|
+
if not texts:
|
|
404
|
+
return TypeSpec.of(AttributeType.STRING), Constraints(), ["no values: string by default"]
|
|
405
|
+
longest = max(len(t) for t in texts)
|
|
406
|
+
n = len(texts)
|
|
407
|
+
ratio = len(distinct) / n
|
|
408
|
+
if (
|
|
409
|
+
n >= options.enum_min_values
|
|
410
|
+
and len(distinct) <= options.enum_max_cardinality
|
|
411
|
+
and ratio <= options.enum_max_distinct_ratio
|
|
412
|
+
and longest <= options.enum_max_value_length
|
|
413
|
+
and not any("\n" in t for t in distinct)
|
|
414
|
+
and not _numbered(distinct)
|
|
415
|
+
):
|
|
416
|
+
return (
|
|
417
|
+
TypeSpec.enum(*sorted(distinct)),
|
|
418
|
+
Constraints(),
|
|
419
|
+
[f"{len(distinct)} distinct values in {n} (ratio {ratio:.2f}): enum"],
|
|
420
|
+
)
|
|
421
|
+
if longest > options.text_min_length:
|
|
422
|
+
return (
|
|
423
|
+
TypeSpec.of(AttributeType.TEXT),
|
|
424
|
+
Constraints(),
|
|
425
|
+
[f"values up to {longest} characters: text"],
|
|
426
|
+
)
|
|
427
|
+
words = sum(len(t.split()) for t in texts) / n
|
|
428
|
+
if matches(snake, options.text_name_hints) and words >= options.text_hint_min_words:
|
|
429
|
+
return (
|
|
430
|
+
TypeSpec.of(AttributeType.TEXT),
|
|
431
|
+
Constraints(),
|
|
432
|
+
[f"prose ({words:.1f} words on average) under a text-like name: text"],
|
|
433
|
+
)
|
|
434
|
+
reasons = [f"text values up to {longest} characters: string"]
|
|
435
|
+
max_length = _bucket(longest, options) if options.propose_max_length else None
|
|
436
|
+
pattern = None
|
|
437
|
+
if options.propose_patterns and len(distinct) >= options.pattern_min_values:
|
|
438
|
+
pattern = shape_pattern(distinct)
|
|
439
|
+
if pattern is not None:
|
|
440
|
+
reasons.append(f"every value has the shape {pattern}")
|
|
441
|
+
if max_length is not None:
|
|
442
|
+
reasons.append(f"max_length {max_length} (longest value rounded up)")
|
|
443
|
+
return (
|
|
444
|
+
TypeSpec.of(AttributeType.STRING),
|
|
445
|
+
Constraints(max_length=max_length, pattern=pattern),
|
|
446
|
+
reasons,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _detect(
|
|
451
|
+
snake: str,
|
|
452
|
+
pairs: Sequence[tuple[object, str]],
|
|
453
|
+
distinct: Sequence[str],
|
|
454
|
+
options: InferenceOptions,
|
|
455
|
+
) -> tuple[TypeSpec, Constraints, float, list[str], MixedTypes | None, float | None]:
|
|
456
|
+
"""Type, constraints, share of conforming values, reasons, mixed-type report, minimum."""
|
|
457
|
+
n = len(pairs)
|
|
458
|
+
texts = [text for _, text in pairs]
|
|
459
|
+
if n == 0:
|
|
460
|
+
spec, constraints, reasons = _textual(snake, texts, distinct, options)
|
|
461
|
+
return spec, constraints, 1.0, reasons, None, None
|
|
462
|
+
tally = _tally(pairs, options)
|
|
463
|
+
needed = math.ceil(n * (1 - options.type_tolerance))
|
|
464
|
+
numeric_min = tally.numeric_min
|
|
465
|
+
non_negative = options.propose_non_negative_min and numeric_min is not None and numeric_min >= 0
|
|
466
|
+
numeric_constraints = Constraints(min=0) if non_negative else Constraints()
|
|
467
|
+
numeric_reason = ["never negative: min 0"] if non_negative else []
|
|
468
|
+
|
|
469
|
+
candidates: list[tuple[AttributeType, int]] = [
|
|
470
|
+
(AttributeType.BOOLEAN, tally.boolean),
|
|
471
|
+
(AttributeType.INTEGER, tally.integer),
|
|
472
|
+
(
|
|
473
|
+
AttributeType.DECIMAL,
|
|
474
|
+
tally.decimal if 0 < tally.scale <= options.decimal_max_scale else 0,
|
|
475
|
+
),
|
|
476
|
+
(AttributeType.FLOAT, tally.floating),
|
|
477
|
+
(AttributeType.DATE, tally.date),
|
|
478
|
+
(AttributeType.TIMESTAMP, tally.timestamp),
|
|
479
|
+
(AttributeType.JSON, tally.json),
|
|
480
|
+
]
|
|
481
|
+
for name, count in candidates:
|
|
482
|
+
if count < needed or count == 0:
|
|
483
|
+
continue
|
|
484
|
+
share = count / n
|
|
485
|
+
mixed = None
|
|
486
|
+
if count < n:
|
|
487
|
+
outliers = tuple(dict.fromkeys(tally.failures.get(name, ())))[
|
|
488
|
+
: options.max_evidence_samples
|
|
489
|
+
]
|
|
490
|
+
mixed = MixedTypes(name, share, outliers, accepted=True)
|
|
491
|
+
reasons = [f"{count} of {n} values are {name}"]
|
|
492
|
+
if name is AttributeType.DECIMAL:
|
|
493
|
+
precision = min(
|
|
494
|
+
options.decimal_max_precision,
|
|
495
|
+
tally.integer_digits + options.decimal_precision_headroom + tally.scale,
|
|
496
|
+
)
|
|
497
|
+
scale = min(tally.scale, precision)
|
|
498
|
+
reasons.append(
|
|
499
|
+
f"up to {tally.integer_digits} integer and {tally.scale} fractional digits"
|
|
500
|
+
)
|
|
501
|
+
return (
|
|
502
|
+
TypeSpec.decimal(precision, scale),
|
|
503
|
+
numeric_constraints,
|
|
504
|
+
share,
|
|
505
|
+
reasons + numeric_reason,
|
|
506
|
+
mixed,
|
|
507
|
+
numeric_min,
|
|
508
|
+
)
|
|
509
|
+
if name is AttributeType.FLOAT and tally.scale > options.decimal_max_scale:
|
|
510
|
+
reasons.append(f"{tally.scale} fractional digits exceed decimal_max_scale")
|
|
511
|
+
if name in (AttributeType.INTEGER, AttributeType.FLOAT):
|
|
512
|
+
return (
|
|
513
|
+
TypeSpec.of(name),
|
|
514
|
+
numeric_constraints,
|
|
515
|
+
share,
|
|
516
|
+
reasons + numeric_reason,
|
|
517
|
+
mixed,
|
|
518
|
+
numeric_min,
|
|
519
|
+
)
|
|
520
|
+
if name is AttributeType.TIMESTAMP and tally.naive_timestamps:
|
|
521
|
+
reasons.append("values without offset are read as UTC")
|
|
522
|
+
return TypeSpec.of(name), Constraints(), share, reasons, mixed, numeric_min
|
|
523
|
+
|
|
524
|
+
spec, constraints, reasons = _textual(snake, texts, distinct, options)
|
|
525
|
+
mixed = None
|
|
526
|
+
best_name, best_count = max(
|
|
527
|
+
((name, count) for name, count in candidates if name is not AttributeType.JSON),
|
|
528
|
+
key=lambda item: item[1],
|
|
529
|
+
)
|
|
530
|
+
if best_count and best_count / n >= options.mixed_type_report_ratio:
|
|
531
|
+
outliers = tuple(dict.fromkeys(tally.failures.get(best_name, ())))[
|
|
532
|
+
: options.max_evidence_samples
|
|
533
|
+
]
|
|
534
|
+
mixed = MixedTypes(best_name, best_count / n, outliers, accepted=False)
|
|
535
|
+
reasons.append(f"{best_count} of {n} values are {best_name}; the others are not")
|
|
536
|
+
return spec, constraints, 1.0, reasons, mixed, None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _key_text(spec: TypeSpec, value: object, text: str) -> str:
|
|
540
|
+
"""Comparable form of a value for uniqueness and references (``007`` stays ``007``)."""
|
|
541
|
+
if spec.name is AttributeType.INTEGER:
|
|
542
|
+
try:
|
|
543
|
+
return str(int(Decimal(text)))
|
|
544
|
+
except (InvalidOperation, ValueError):
|
|
545
|
+
return text
|
|
546
|
+
return text
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def analyze_column(profile: ColumnProfile, index: int, options: InferenceOptions) -> ColumnAnalysis:
|
|
550
|
+
"""Statistics, type, nullability and uniqueness of one column."""
|
|
551
|
+
raw = list(islice(profile.values, options.sample_size))
|
|
552
|
+
texts = [text_of(value, options) for value in raw]
|
|
553
|
+
pairs = [(value, text) for value, text in zip(raw, texts, strict=True) if text is not None]
|
|
554
|
+
nulls = len(raw) - len(pairs)
|
|
555
|
+
distinct = list(dict.fromkeys(text for _, text in pairs))
|
|
556
|
+
snake = normalize(profile.name)
|
|
557
|
+
|
|
558
|
+
if profile.declared_type is not None:
|
|
559
|
+
spec = profile.declared_type
|
|
560
|
+
max_length = profile.declared_max_length
|
|
561
|
+
constraints = (
|
|
562
|
+
Constraints(max_length=max_length) if max_length is not None else Constraints()
|
|
563
|
+
)
|
|
564
|
+
share, mixed, numeric_min = 1.0, None, None
|
|
565
|
+
reasons = [f"declared type {spec.describe()}"]
|
|
566
|
+
if max_length is not None:
|
|
567
|
+
reasons.append(f"declared max_length {max_length}")
|
|
568
|
+
else:
|
|
569
|
+
spec, constraints, share, reasons, mixed, numeric_min = _detect(
|
|
570
|
+
snake, pairs, distinct, options
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
row_keys = tuple(
|
|
574
|
+
None if text is None else _key_text(spec, value, text)
|
|
575
|
+
for value, text in zip(raw, texts, strict=True)
|
|
576
|
+
)
|
|
577
|
+
keys = [k for k in row_keys if k is not None]
|
|
578
|
+
distinct_keys = frozenset(keys)
|
|
579
|
+
non_null = len(keys)
|
|
580
|
+
|
|
581
|
+
if profile.nullable is not None:
|
|
582
|
+
required = not profile.nullable
|
|
583
|
+
reasons.append("declared NOT NULL" if required else "declared nullable")
|
|
584
|
+
else:
|
|
585
|
+
required = bool(raw) and nulls / len(raw) <= options.required_max_null_ratio
|
|
586
|
+
reasons.append(
|
|
587
|
+
f"no nulls in {len(raw)} values: required"
|
|
588
|
+
if required and not nulls
|
|
589
|
+
else f"{nulls} nulls in {len(raw)} values"
|
|
590
|
+
+ (": required" if required else ": optional")
|
|
591
|
+
)
|
|
592
|
+
observed_unique = non_null >= options.unique_min_values and len(distinct_keys) == non_null
|
|
593
|
+
unique = bool(profile.unique) or observed_unique
|
|
594
|
+
if profile.unique:
|
|
595
|
+
reasons.append("declared unique")
|
|
596
|
+
elif observed_unique:
|
|
597
|
+
reasons.append(f"{non_null} distinct values in {non_null}: unique")
|
|
598
|
+
|
|
599
|
+
confidence = 1.0 if profile.declared_type is not None else share * support(non_null, options)
|
|
600
|
+
return ColumnAnalysis(
|
|
601
|
+
name=profile.name,
|
|
602
|
+
snake=snake,
|
|
603
|
+
index=index,
|
|
604
|
+
count=len(raw),
|
|
605
|
+
nulls=nulls,
|
|
606
|
+
row_keys=row_keys,
|
|
607
|
+
distinct_keys=distinct_keys,
|
|
608
|
+
type=spec,
|
|
609
|
+
constraints=constraints,
|
|
610
|
+
required=required,
|
|
611
|
+
unique=unique,
|
|
612
|
+
declared_unique=bool(profile.unique),
|
|
613
|
+
declared_type=profile.declared_type is not None,
|
|
614
|
+
confidence=round(confidence, 4),
|
|
615
|
+
reasons=tuple(reasons),
|
|
616
|
+
samples=tuple(distinct[: options.max_evidence_samples]),
|
|
617
|
+
generated_ids=generated_ids(distinct),
|
|
618
|
+
mixed=mixed,
|
|
619
|
+
comment=profile.comment,
|
|
620
|
+
numeric_min=numeric_min,
|
|
621
|
+
)
|