floe-python 0.8.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.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/checks/mismatch.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Schema mismatch: missing/extra columns and their policies (Rust: `checks/mismatch.rs`)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from floe.checks.normalize import normalize_name, resolve_strategy, source_names
|
|
10
|
+
from floe.config.types import EntityConfig
|
|
11
|
+
from floe.errors import FloeConfigError, FloeIoError
|
|
12
|
+
from floe.read.json import json_records
|
|
13
|
+
|
|
14
|
+
MAX_MISMATCH_COLUMNS = 50
|
|
15
|
+
# nested-path markers: `/` and `@` are XML path syntax, but ordinary characters in a JSON key
|
|
16
|
+
_NESTED = {"json": (".", "["), "xml": (".", "[", "/", "@")}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MismatchOutcome:
|
|
21
|
+
"""`report` is the `mismatch` object of the file report; the rest drives the run."""
|
|
22
|
+
|
|
23
|
+
report: dict[str, Any]
|
|
24
|
+
rejected: bool
|
|
25
|
+
aborted: bool
|
|
26
|
+
warnings: int
|
|
27
|
+
errors: int
|
|
28
|
+
missing: list[str]
|
|
29
|
+
extra: list[str]
|
|
30
|
+
fill_missing: bool
|
|
31
|
+
ignore_extra: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def declared_names(entity: EntityConfig) -> list[str]:
|
|
35
|
+
"""The declared names input columns are compared with (normalised)."""
|
|
36
|
+
schema = entity.schema_
|
|
37
|
+
strategy = resolve_strategy(schema)
|
|
38
|
+
if entity.source.format not in ("json", "xml"):
|
|
39
|
+
return source_names(schema.columns, strategy)
|
|
40
|
+
# Nested selectors are not file columns; only the top-level ones are compared.
|
|
41
|
+
names = [
|
|
42
|
+
normalize_name(c.source or c.name, strategy)
|
|
43
|
+
for c in schema.columns
|
|
44
|
+
if not any(t in (c.source or c.name) for t in _NESTED[entity.source.format])
|
|
45
|
+
]
|
|
46
|
+
if len(set(names)) != len(names):
|
|
47
|
+
dup = next(n for n in names if names.count(n) > 1)
|
|
48
|
+
raise FloeConfigError(f"duplicate top-level column selector: {dup}")
|
|
49
|
+
return names
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plan_mismatch(
|
|
53
|
+
entity: EntityConfig, declared: list[str], input_names: list[str]
|
|
54
|
+
) -> MismatchOutcome:
|
|
55
|
+
strategy = resolve_strategy(entity.schema_)
|
|
56
|
+
inputs = [normalize_name(n, strategy) for n in input_names]
|
|
57
|
+
missing = sorted(set(declared) - set(inputs))
|
|
58
|
+
extra = sorted(set(inputs) - set(declared))
|
|
59
|
+
policy = entity.schema_.mismatch
|
|
60
|
+
on_missing = (policy and policy.missing_columns) or "fill_nulls"
|
|
61
|
+
on_extra = (policy and policy.extra_columns) or "ignore"
|
|
62
|
+
severity = entity.policy.severity
|
|
63
|
+
|
|
64
|
+
def requested() -> bool:
|
|
65
|
+
return (on_missing == "reject_file" and bool(missing)) or (
|
|
66
|
+
on_extra == "reject_file" and bool(extra)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
warning = None
|
|
70
|
+
if requested() and severity == "warn":
|
|
71
|
+
warning = (
|
|
72
|
+
f"entity.name={entity.name} schema mismatch requested reject_file "
|
|
73
|
+
"but policy.severity=warn; continuing"
|
|
74
|
+
)
|
|
75
|
+
on_missing, on_extra = "fill_nulls", "ignore"
|
|
76
|
+
rejected = requested() and severity == "reject"
|
|
77
|
+
aborted = requested() and severity == "abort"
|
|
78
|
+
fill = not (rejected or aborted) and on_missing == "fill_nulls" and bool(missing)
|
|
79
|
+
ignore = not (rejected or aborted) and on_extra == "ignore" and bool(extra)
|
|
80
|
+
action = (
|
|
81
|
+
"rejected_file" if rejected
|
|
82
|
+
else "aborted" if aborted
|
|
83
|
+
else "filled_nulls" if fill
|
|
84
|
+
else "ignored_extras" if ignore
|
|
85
|
+
else "none"
|
|
86
|
+
) # fmt: skip
|
|
87
|
+
report: dict[str, Any] = {
|
|
88
|
+
"declared_columns_count": len(declared),
|
|
89
|
+
"input_columns_count": len(inputs),
|
|
90
|
+
"missing_columns": missing[:MAX_MISMATCH_COLUMNS],
|
|
91
|
+
"extra_columns": extra[:MAX_MISMATCH_COLUMNS],
|
|
92
|
+
"mismatch_action": action,
|
|
93
|
+
}
|
|
94
|
+
if rejected or aborted:
|
|
95
|
+
report["error"] = {
|
|
96
|
+
"rule": "schema_mismatch",
|
|
97
|
+
"message": f"entity.name={entity.name} schema mismatch: "
|
|
98
|
+
f"missing={len(missing)} extra={len(extra)}",
|
|
99
|
+
}
|
|
100
|
+
if warning:
|
|
101
|
+
report["warning"] = warning
|
|
102
|
+
return MismatchOutcome(
|
|
103
|
+
report, rejected, aborted, int(bool(warning)), int(rejected or aborted),
|
|
104
|
+
missing, extra, fill, ignore,
|
|
105
|
+
) # fmt: skip
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_mismatch(df: pl.DataFrame, plan: MismatchOutcome) -> pl.DataFrame:
|
|
109
|
+
"""Add the missing columns as nulls and drop the extra ones, as the plan says."""
|
|
110
|
+
if plan.fill_missing:
|
|
111
|
+
# a column the reader already produced (an XML `a:id` selector the probe names `id`) stays
|
|
112
|
+
df = df.with_columns(
|
|
113
|
+
pl.lit(None, pl.String).alias(n)
|
|
114
|
+
for n in plan.missing
|
|
115
|
+
if n not in df.columns
|
|
116
|
+
)
|
|
117
|
+
if plan.ignore_extra:
|
|
118
|
+
df = df.drop(plan.extra, strict=False)
|
|
119
|
+
return df
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def json_input_columns(path: Path, mode: str = "array") -> list[str] | None:
|
|
123
|
+
"""Sorted top-level keys of every record, read exactly as `read_json` reads them.
|
|
124
|
+
|
|
125
|
+
D40: the union over all records, so a key only a later record carries is still an input
|
|
126
|
+
column (Rust 0.7 probed the first object only). `None` for a file without records (D41), which
|
|
127
|
+
differs from records that carry no keys (`[]`): those are still checked for missing columns.
|
|
128
|
+
"""
|
|
129
|
+
if mode not in ("array", "ndjson"):
|
|
130
|
+
raise FloeIoError(
|
|
131
|
+
f"unsupported source.options.json_mode={mode} (allowed: array, ndjson)"
|
|
132
|
+
)
|
|
133
|
+
# ponytail: the file is parsed twice (probe, then read); fold the probe into the read if it costs
|
|
134
|
+
keys: set[str] | None = None
|
|
135
|
+
for _, record in json_records(path, mode):
|
|
136
|
+
keys = (keys or set[str]()) | {str(k) for k in record}
|
|
137
|
+
return None if keys is None else sorted(keys)
|
floe/checks/normalize.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Column-name normalization (Rust: `checks/normalize.rs`). ASCII only, as Rust."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from string import ascii_lowercase, ascii_uppercase
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from floe.config.types import ColumnConfig, SchemaConfig
|
|
9
|
+
from floe.errors import FloeConfigError
|
|
10
|
+
|
|
11
|
+
_STRATEGIES = {"snakecase", "lower", "camelcase", "none"}
|
|
12
|
+
_LOWER = str.maketrans(ascii_uppercase, ascii_lowercase)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _canonical(value: str) -> str:
|
|
16
|
+
return value.lower().replace("-", "").replace("_", "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_strategy(schema: SchemaConfig) -> str | None:
|
|
20
|
+
"""The canonical strategy when `normalize_columns.enabled`, else None."""
|
|
21
|
+
config = schema.normalize_columns
|
|
22
|
+
if config is None or not config.enabled:
|
|
23
|
+
return None
|
|
24
|
+
raw = config.strategy or "snake_case"
|
|
25
|
+
if (strategy := _canonical(raw)) not in _STRATEGIES:
|
|
26
|
+
raise FloeConfigError(f"unsupported normalize_columns.strategy: {raw}")
|
|
27
|
+
return strategy
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _words(value: str) -> list[str]:
|
|
31
|
+
words: list[str] = []
|
|
32
|
+
current = ""
|
|
33
|
+
for i, ch in enumerate(value):
|
|
34
|
+
if not (ch.isascii() and ch.isalnum()):
|
|
35
|
+
if current:
|
|
36
|
+
words.append(current)
|
|
37
|
+
current = ""
|
|
38
|
+
continue
|
|
39
|
+
prev = value[i - 1] if i else ""
|
|
40
|
+
nxt = value[i + 1] if i + 1 < len(value) else ""
|
|
41
|
+
prev_lower = prev.isascii() and prev.islower()
|
|
42
|
+
prev_digit = prev.isascii() and prev.isdigit()
|
|
43
|
+
prev_upper = prev.isascii() and prev.isupper()
|
|
44
|
+
next_lower = nxt.isascii() and nxt.islower()
|
|
45
|
+
if (
|
|
46
|
+
current
|
|
47
|
+
and ch.isupper()
|
|
48
|
+
and (prev_lower or prev_digit or (prev_upper and next_lower))
|
|
49
|
+
):
|
|
50
|
+
words.append(current)
|
|
51
|
+
current = ""
|
|
52
|
+
current += ch.translate(_LOWER)
|
|
53
|
+
return [*words, current] if current else words
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_name(value: str, strategy: str | None) -> str:
|
|
57
|
+
match None if strategy is None else _canonical(strategy):
|
|
58
|
+
case "snakecase":
|
|
59
|
+
return "_".join(_words(value))
|
|
60
|
+
case "lower":
|
|
61
|
+
return value.translate(_LOWER)
|
|
62
|
+
case "camelcase":
|
|
63
|
+
return "".join(
|
|
64
|
+
w if i == 0 else w[:1].upper() + w[1:]
|
|
65
|
+
for i, w in enumerate(_words(value))
|
|
66
|
+
)
|
|
67
|
+
case _:
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def source_names(columns: Sequence[ColumnConfig], strategy: str | None) -> list[str]:
|
|
72
|
+
"""Normalised `source or name` of each column; two columns on one name are an error."""
|
|
73
|
+
seen: dict[str, str] = {}
|
|
74
|
+
for column in columns:
|
|
75
|
+
source = column.source or column.name
|
|
76
|
+
name = normalize_name(source, strategy)
|
|
77
|
+
if name in seen:
|
|
78
|
+
raise FloeConfigError(
|
|
79
|
+
f"column source collision: {seen[name]} and {column.name} -> {name}"
|
|
80
|
+
)
|
|
81
|
+
seen[name] = source
|
|
82
|
+
return list(seen)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def output_names(columns: Sequence[ColumnConfig], strategy: str | None) -> list[str]:
|
|
86
|
+
"""Output name of each column: an explicit `source` keeps `name`, otherwise it is normalised."""
|
|
87
|
+
return [c.name if c.source else normalize_name(c.name, strategy) for c in columns]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def output_column_mapping(
|
|
91
|
+
columns: Sequence[ColumnConfig], strategy: str | None
|
|
92
|
+
) -> dict[str, str]:
|
|
93
|
+
"""Normalised source name -> output name, for the columns the rename changes."""
|
|
94
|
+
targets: dict[str, str] = {}
|
|
95
|
+
mapping: dict[str, str] = {}
|
|
96
|
+
for column, target in zip(columns, output_names(columns, strategy), strict=True):
|
|
97
|
+
source = normalize_name(column.source or column.name, strategy)
|
|
98
|
+
if target in targets:
|
|
99
|
+
raise FloeConfigError(
|
|
100
|
+
f"output column name collision: {targets[target]} and {source} -> {target}"
|
|
101
|
+
)
|
|
102
|
+
targets[target] = source
|
|
103
|
+
if source != target:
|
|
104
|
+
mapping[source] = target
|
|
105
|
+
return mapping
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalize_columns(df: pl.DataFrame, strategy: str | None) -> pl.DataFrame:
|
|
109
|
+
"""Rename the frame's columns; two columns on one name are an error."""
|
|
110
|
+
names = {c: normalize_name(c, strategy) for c in df.columns}
|
|
111
|
+
seen: dict[str, str] = {}
|
|
112
|
+
for old, new in names.items():
|
|
113
|
+
if new in seen:
|
|
114
|
+
raise FloeConfigError(
|
|
115
|
+
f"normalized input column collision: {seen[new]} and {old} -> {new}"
|
|
116
|
+
)
|
|
117
|
+
seen[new] = old
|
|
118
|
+
return df.rename(names)
|
floe/checks/rows.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""not_null, row error columns and the accept/reject split (Rust: `checks/not_null.rs`, `checks/mod.rs`)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from floe.checks.cast import json_escape
|
|
10
|
+
|
|
11
|
+
NOT_NULL_MESSAGE = "required value missing"
|
|
12
|
+
ROW_INDEX = "__floe_row_index"
|
|
13
|
+
ERRORS = "__floe_errors"
|
|
14
|
+
|
|
15
|
+
Formatter = Literal[
|
|
16
|
+
"json", "csv", "text"
|
|
17
|
+
] # `report.formatter`, validated with the config
|
|
18
|
+
|
|
19
|
+
# (prefix, separator, suffix) around the joined entries. csv and text are JSON strings: the
|
|
20
|
+
# whole value is escaped and quoted, so the csv separator `\n` is written as the two characters.
|
|
21
|
+
_WRAP: dict[Formatter, tuple[str, str, str]] = {
|
|
22
|
+
"json": ("[", ",", "]"),
|
|
23
|
+
"csv": ('"', "\n", '"'),
|
|
24
|
+
"text": ('"', "; ", '"'),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RowCheck:
|
|
30
|
+
"""One check over the rows: `mask` is true where the row violates it.
|
|
31
|
+
|
|
32
|
+
Pass the checks in Rust's error order: not_null (required columns), cast_error (declared
|
|
33
|
+
non-string columns), then unique.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
rule: str
|
|
37
|
+
column: str
|
|
38
|
+
message: str
|
|
39
|
+
mask: pl.Series
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def not_null_check(df: pl.DataFrame, column: str) -> RowCheck:
|
|
43
|
+
"""A required column. `df` holds the typed values, so a failed cast also violates it."""
|
|
44
|
+
return RowCheck("not_null", column, NOT_NULL_MESSAGE, df[column].is_null())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def error_entry(rule: str, column: str, message: str, source: str | None = None) -> str:
|
|
48
|
+
"""Rust's `RowError::to_json_with_source`."""
|
|
49
|
+
src = "" if source is None else f'"source":"{json_escape(source)}",'
|
|
50
|
+
return (
|
|
51
|
+
f'{{"rule":"{json_escape(rule)}","column":"{json_escape(column)}",{src}'
|
|
52
|
+
f'"message":"{json_escape(message)}"}}'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _csv_escape(value: str) -> str:
|
|
57
|
+
escaped = value.replace('"', '""')
|
|
58
|
+
return f'"{escaped}"' if any(c in escaped for c in ",\n\r") else escaped
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _entry(
|
|
62
|
+
formatter: Formatter, check: RowCheck, sources: Mapping[str, str] | None
|
|
63
|
+
) -> str:
|
|
64
|
+
"""What one violated check adds to a row's errors, per Rust `RowErrorFormatter`."""
|
|
65
|
+
source = sources.get(check.column) if sources else None
|
|
66
|
+
rule, column, message = check.rule, check.column, check.message
|
|
67
|
+
if formatter == "json":
|
|
68
|
+
return error_entry(rule, column, message, source)
|
|
69
|
+
if formatter == "csv":
|
|
70
|
+
fields = [rule, column, message]
|
|
71
|
+
if (
|
|
72
|
+
sources
|
|
73
|
+
): # with a source map every line has the source field, empty if unmapped
|
|
74
|
+
fields.insert(2, source or "")
|
|
75
|
+
return ",".join(_csv_escape(f) for f in fields)
|
|
76
|
+
src = "" if source is None else f" source={source}"
|
|
77
|
+
return f"{rule}:{column}{src} {message}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def error_column(
|
|
81
|
+
height: int,
|
|
82
|
+
checks: Sequence[RowCheck],
|
|
83
|
+
formatter: Formatter = "json",
|
|
84
|
+
sources: Mapping[str, str] | None = None,
|
|
85
|
+
) -> pl.Series:
|
|
86
|
+
"""`__floe_errors`: the formatted errors of each row, null for rows that pass every check.
|
|
87
|
+
|
|
88
|
+
`sources` maps a runtime column name to its source name (Rust's `source_column_map`).
|
|
89
|
+
"""
|
|
90
|
+
if not checks:
|
|
91
|
+
return pl.Series(ERRORS, [None] * height, dtype=pl.String)
|
|
92
|
+
head, sep, tail = _WRAP[formatter]
|
|
93
|
+
escape = str if formatter == "json" else json_escape
|
|
94
|
+
entries = [
|
|
95
|
+
pl.when(c.mask).then(pl.lit(escape(_entry(formatter, c, sources))))
|
|
96
|
+
for c in checks
|
|
97
|
+
]
|
|
98
|
+
joined = pl.concat_str(entries, separator=escape(sep), ignore_nulls=True)
|
|
99
|
+
failed = pl.any_horizontal([c.mask for c in checks])
|
|
100
|
+
return pl.select(
|
|
101
|
+
pl.when(failed).then(pl.lit(head) + joined + pl.lit(tail)).alias(ERRORS)
|
|
102
|
+
)[ERRORS]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def split_rows(
|
|
106
|
+
df: pl.DataFrame,
|
|
107
|
+
checks: Sequence[RowCheck],
|
|
108
|
+
formatter: Formatter = "json",
|
|
109
|
+
sources: Mapping[str, str] | None = None,
|
|
110
|
+
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
111
|
+
"""`(accepted, rejected)`; rejected adds `__floe_row_index` (UInt64, row position in `df`) and `__floe_errors`."""
|
|
112
|
+
errors = error_column(df.height, checks, formatter, sources)
|
|
113
|
+
rejected = df.with_columns(
|
|
114
|
+
pl.int_range(pl.len(), dtype=pl.UInt64).alias(ROW_INDEX), errors
|
|
115
|
+
).filter(errors.is_not_null())
|
|
116
|
+
return df.filter(errors.is_null()), rejected
|
floe/checks/unique.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Unique keys within a file and across the files of one run (Rust: `checks/unique.rs`)."""
|
|
2
|
+
# pyarrow ships no type stubs, so strict pyright cannot see its types.
|
|
3
|
+
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false
|
|
4
|
+
# pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
|
|
15
|
+
from floe.checks.rows import RowCheck
|
|
16
|
+
|
|
17
|
+
SAMPLE_LIMIT = 5
|
|
18
|
+
_SEEN = "__floe_seen"
|
|
19
|
+
_DEBUG = "__floe_debug"
|
|
20
|
+
_COUNT = "__floe_count"
|
|
21
|
+
_BITS = "_bits" # suffix of the bit-pattern column that keys a float column
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class UniqueSample:
|
|
26
|
+
values: dict[str, str]
|
|
27
|
+
count: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class UniqueResult:
|
|
32
|
+
"""Rust's `UniqueConstraintResult`; every duplicate is a batch duplicate (no target history)."""
|
|
33
|
+
|
|
34
|
+
columns: list[str]
|
|
35
|
+
duplicates_count: int
|
|
36
|
+
samples: list[UniqueSample]
|
|
37
|
+
target_duplicates_count: int = 0 # duplicates of keys seeded from the existing sink
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_unique_keys(
|
|
41
|
+
unique_keys: Sequence[Sequence[str]] | None,
|
|
42
|
+
unique_columns: Sequence[str],
|
|
43
|
+
primary_key: Sequence[str] | None,
|
|
44
|
+
) -> list[list[str]]:
|
|
45
|
+
"""Rust's `resolve_schema_unique_keys`: `unique_keys`, else the `unique: true` columns, then the primary key.
|
|
46
|
+
|
|
47
|
+
Names are trimmed; empty and repeated keys are dropped.
|
|
48
|
+
"""
|
|
49
|
+
keys = unique_keys if unique_keys is not None else [[c] for c in unique_columns]
|
|
50
|
+
resolved: list[list[str]] = []
|
|
51
|
+
for key in [*keys, *([primary_key] if primary_key else [])]:
|
|
52
|
+
names = [c.strip() for c in key]
|
|
53
|
+
if names and names not in resolved:
|
|
54
|
+
resolved.append(names)
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# `str` Debug; ponytail: non-ASCII non-printable characters (Rust `\\u{..}`) are left raw
|
|
59
|
+
_STR_ESCAPES = {
|
|
60
|
+
"\\": "\\\\",
|
|
61
|
+
'"': '\\"',
|
|
62
|
+
"\n": "\\n",
|
|
63
|
+
"\r": "\\r",
|
|
64
|
+
"\t": "\\t",
|
|
65
|
+
"\0": "\\0",
|
|
66
|
+
**{chr(i): f"\\u{{{i:x}}}" for i in [*range(1, 32), 127] if chr(i) not in "\n\r\t"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _float_bits(s: pl.Series) -> pl.Series:
|
|
71
|
+
"""Rust's `to_bits()`: Polars cannot reinterpret a float, so view the Arrow buffer."""
|
|
72
|
+
return pl.Series(s.cast(pl.Float64).to_arrow().view(pa.uint64()))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _debug(name: str, dtype: pl.DataType) -> pl.Expr:
|
|
76
|
+
"""Rust's `{:?}` of the `UniqueKey` variant: the sample tie-break sorts on these strings."""
|
|
77
|
+
c = pl.col(name)
|
|
78
|
+
if dtype == pl.String:
|
|
79
|
+
escaped = c
|
|
80
|
+
for raw, esc in _STR_ESCAPES.items():
|
|
81
|
+
escaped = escaped.str.replace_all(raw, esc, literal=True)
|
|
82
|
+
return pl.format('String("{}")', escaped)
|
|
83
|
+
if dtype == pl.Boolean:
|
|
84
|
+
return pl.format("Bool({})", c.cast(pl.String))
|
|
85
|
+
if dtype.is_signed_integer():
|
|
86
|
+
return pl.format("I64({})", c.cast(pl.String))
|
|
87
|
+
if dtype.is_unsigned_integer():
|
|
88
|
+
return pl.format("U64({})", c.cast(pl.String))
|
|
89
|
+
if dtype.is_float():
|
|
90
|
+
return pl.format("F64({})", pl.col(name + _BITS).cast(pl.String))
|
|
91
|
+
if isinstance(dtype, pl.Datetime): # chrono prints the fraction only when not zero
|
|
92
|
+
return pl.format('Other("{}")', c.dt.to_string("%Y-%m-%d %H:%M:%S%.f"))
|
|
93
|
+
# ponytail: date keys use `Other(display)`, unverified against Rust; ties may order differently
|
|
94
|
+
return pl.format('Other("{}")', c.cast(pl.String))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _text(value: object) -> str:
|
|
98
|
+
"""Rust's `to_string` of a key value."""
|
|
99
|
+
if isinstance(value, bool):
|
|
100
|
+
return "true" if value else "false"
|
|
101
|
+
if isinstance(value, float):
|
|
102
|
+
if not math.isfinite(value):
|
|
103
|
+
return "NaN" if math.isnan(value) else str(value).replace("infinity", "inf")
|
|
104
|
+
return format(Decimal(repr(value)).normalize(), "f")
|
|
105
|
+
if isinstance(value, datetime):
|
|
106
|
+
if not value.microsecond:
|
|
107
|
+
spec = "seconds"
|
|
108
|
+
else:
|
|
109
|
+
spec = "milliseconds" if value.microsecond % 1000 == 0 else "microseconds"
|
|
110
|
+
return value.replace(tzinfo=None).isoformat(sep=" ", timespec=spec)
|
|
111
|
+
return str(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class _Constraint:
|
|
116
|
+
columns: list[str]
|
|
117
|
+
seen: pl.DataFrame
|
|
118
|
+
dup_counts: list[pl.DataFrame] = field(default_factory=list[pl.DataFrame])
|
|
119
|
+
duplicates: int = 0
|
|
120
|
+
target: int = 0
|
|
121
|
+
seeded: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def ids(self) -> list[str]:
|
|
125
|
+
"""Positional names, so key columns named like our temporaries or regexes stay literal."""
|
|
126
|
+
return [f"k{i}" for i in range(len(self.columns))]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class UniqueTracker:
|
|
130
|
+
"""Keeps the keys seen so far, so a key repeated in a later file is a duplicate too."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, keys: Sequence[Sequence[str]]) -> None:
|
|
133
|
+
self._constraints = [_Constraint(list(k), pl.DataFrame()) for k in keys]
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def keys(self) -> list[list[str]]:
|
|
137
|
+
return [list(c.columns) for c in self._constraints]
|
|
138
|
+
|
|
139
|
+
def check(self, df: pl.DataFrame) -> list[RowCheck]:
|
|
140
|
+
"""One `RowCheck` per constraint for the rows of the next file, in constraint order.
|
|
141
|
+
|
|
142
|
+
Pass every row, rejected ones included. A row with a NULL in any key column is
|
|
143
|
+
skipped: never a duplicate, never remembered.
|
|
144
|
+
"""
|
|
145
|
+
return [self._apply(c, df) for c in self._constraints]
|
|
146
|
+
|
|
147
|
+
def snapshot(self) -> list[tuple[pl.DataFrame, list[pl.DataFrame], int, int]]:
|
|
148
|
+
"""The state `restore` goes back to: a file that is rejected as unreadable leaves no keys."""
|
|
149
|
+
return [
|
|
150
|
+
(c.seen, list(c.dup_counts), c.duplicates, c.target)
|
|
151
|
+
for c in self._constraints
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def restore(
|
|
155
|
+
self, snapshot: list[tuple[pl.DataFrame, list[pl.DataFrame], int, int]]
|
|
156
|
+
) -> None:
|
|
157
|
+
for c, (seen, counts, dups, target) in zip(
|
|
158
|
+
self._constraints, snapshot, strict=True
|
|
159
|
+
):
|
|
160
|
+
c.seen, c.dup_counts, c.duplicates, c.target = (
|
|
161
|
+
seen,
|
|
162
|
+
list(counts),
|
|
163
|
+
dups,
|
|
164
|
+
target,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def seed(self, df: pl.DataFrame) -> None:
|
|
168
|
+
"""Remember the keys of rows already in the sink (append mode); a later hit is a target duplicate."""
|
|
169
|
+
for c in self._constraints:
|
|
170
|
+
if all(n in df.columns for n in c.columns):
|
|
171
|
+
keys = self._keys(c, df)
|
|
172
|
+
valid = keys.select(pl.all_horizontal(pl.all().is_not_null()))[c.ids[0]]
|
|
173
|
+
fresh = keys.filter(valid).unique()
|
|
174
|
+
c.seen = pl.concat([c.seen, fresh]).unique() if c.seen.width else fresh
|
|
175
|
+
# Iceberg seeds chunk by chunk: keys the batch already holds stay batch keys
|
|
176
|
+
c.seeded = (
|
|
177
|
+
pl.concat([c.seeded, fresh]).unique() if c.seeded.width else fresh
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _keys(c: _Constraint, df: pl.DataFrame) -> pl.DataFrame:
|
|
182
|
+
keys = pl.DataFrame(
|
|
183
|
+
[df[n].alias(k) for n, k in zip(c.columns, c.ids, strict=True)]
|
|
184
|
+
)
|
|
185
|
+
# Rust compares `to_bits()`: -0.0 differs from 0.0, NaN payloads differ; key on the bits too
|
|
186
|
+
return keys.with_columns(
|
|
187
|
+
pl.col(k).map_batches(_float_bits, return_dtype=pl.UInt64).alias(k + _BITS)
|
|
188
|
+
for k in c.ids
|
|
189
|
+
if keys.schema[k].is_float()
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _apply(self, c: _Constraint, df: pl.DataFrame) -> RowCheck:
|
|
193
|
+
keys = self._keys(c, df)
|
|
194
|
+
valid = keys.select(pl.all_horizontal(pl.all().is_not_null()))[c.ids[0]]
|
|
195
|
+
dup = pl.Series([False] * df.height)
|
|
196
|
+
if (
|
|
197
|
+
valid.any()
|
|
198
|
+
): # skips all-null (Null dtype) frames, which cannot join the seen keys
|
|
199
|
+
first = keys.select(pl.struct(keys.columns).is_first_distinct())[c.ids[0]]
|
|
200
|
+
dup = valid & ~first
|
|
201
|
+
if c.seen.width:
|
|
202
|
+
hit = keys.join(
|
|
203
|
+
c.seen.with_columns(pl.lit(True).alias(_SEEN)),
|
|
204
|
+
on=keys.columns,
|
|
205
|
+
how="left",
|
|
206
|
+
maintain_order="left",
|
|
207
|
+
)[_SEEN]
|
|
208
|
+
dup = dup | (valid & hit.fill_null(False))
|
|
209
|
+
fresh = keys.filter(valid & ~dup)
|
|
210
|
+
c.seen = pl.concat([c.seen, fresh]) if c.seen.width else fresh
|
|
211
|
+
dups = keys.filter(dup)
|
|
212
|
+
c.duplicates += dups.height
|
|
213
|
+
if c.seeded.width:
|
|
214
|
+
c.target += dups.join(c.seeded, on=keys.columns, how="semi").height
|
|
215
|
+
if dups.height:
|
|
216
|
+
c.dup_counts.append(dups.group_by(keys.columns).len(_COUNT))
|
|
217
|
+
single = len(c.columns) == 1
|
|
218
|
+
name = c.columns[0] if single else f"[{','.join(c.columns)}]"
|
|
219
|
+
message = "duplicate value" if single else "duplicate key"
|
|
220
|
+
return RowCheck("unique", name, message, dup)
|
|
221
|
+
|
|
222
|
+
def results(self) -> list[UniqueResult]:
|
|
223
|
+
"""Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by Rust's Debug string."""
|
|
224
|
+
return [self._result(c) for c in self._constraints]
|
|
225
|
+
|
|
226
|
+
def _result(self, c: _Constraint) -> UniqueResult:
|
|
227
|
+
if not c.dup_counts:
|
|
228
|
+
return UniqueResult(c.columns, 0, [])
|
|
229
|
+
parts = pl.concat(c.dup_counts)
|
|
230
|
+
counts = parts.group_by(parts.columns[:-1]).agg(pl.col(_COUNT).sum())
|
|
231
|
+
debug = pl.format(
|
|
232
|
+
"CompositeKey([{}])",
|
|
233
|
+
pl.concat_str([_debug(k, counts.schema[k]) for k in c.ids], separator=", "),
|
|
234
|
+
)
|
|
235
|
+
top = (
|
|
236
|
+
counts.with_columns(debug.alias(_DEBUG))
|
|
237
|
+
.sort([_COUNT, _DEBUG], descending=[True, False])
|
|
238
|
+
.head(SAMPLE_LIMIT)
|
|
239
|
+
)
|
|
240
|
+
samples = [ # at most SAMPLE_LIMIT rows
|
|
241
|
+
UniqueSample(
|
|
242
|
+
{n: _text(row[k]) for n, k in zip(c.columns, c.ids, strict=True)},
|
|
243
|
+
row[_COUNT],
|
|
244
|
+
)
|
|
245
|
+
for row in top.iter_rows(named=True)
|
|
246
|
+
]
|
|
247
|
+
return UniqueResult(c.columns, c.duplicates, samples, c.target)
|
floe/config/__init__.py
ADDED
|
File without changes
|