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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. 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
+ )