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.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
@@ -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)
@@ -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)
File without changes