floe-python 0.8.3__py3-none-any.whl → 0.8.4__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/checks/normalize.py +1 -1
- floe/checks/unique.py +51 -1
- floe/config/validate.py +10 -1
- floe/iceberg/catalog.py +10 -5
- floe/iceberg/sink.py +22 -14
- floe/iceberg/unique.py +16 -50
- floe/iceberg/write.py +7 -6
- floe/report/entity.py +4 -2
- floe/run/entity.py +122 -71
- floe/write/outputs.py +37 -23
- {floe_python-0.8.3.dist-info → floe_python-0.8.4.dist-info}/METADATA +1 -1
- {floe_python-0.8.3.dist-info → floe_python-0.8.4.dist-info}/RECORD +15 -15
- {floe_python-0.8.3.dist-info → floe_python-0.8.4.dist-info}/WHEEL +0 -0
- {floe_python-0.8.3.dist-info → floe_python-0.8.4.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.3.dist-info → floe_python-0.8.4.dist-info}/licenses/LICENSE +0 -0
floe/checks/normalize.py
CHANGED
|
@@ -76,7 +76,7 @@ def source_names(columns: Sequence[ColumnConfig], strategy: str | None) -> list[
|
|
|
76
76
|
name = normalize_name(source, strategy)
|
|
77
77
|
if name in seen:
|
|
78
78
|
raise FloeConfigError(
|
|
79
|
-
f"column source collision: {seen[name]} and {
|
|
79
|
+
f"column source collision: {seen[name]} and {source} -> {name}"
|
|
80
80
|
)
|
|
81
81
|
seen[name] = source
|
|
82
82
|
return list(seen)
|
floe/checks/unique.py
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
# pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
5
5
|
|
|
6
6
|
import math
|
|
7
|
-
from collections.abc import Sequence
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from decimal import Decimal
|
|
11
11
|
|
|
12
12
|
import polars as pl
|
|
13
|
+
import polars.selectors as cs
|
|
13
14
|
import pyarrow as pa
|
|
14
15
|
|
|
15
16
|
from floe.checks.rows import RowCheck
|
|
@@ -245,3 +246,52 @@ class UniqueTracker:
|
|
|
245
246
|
for row in top.iter_rows(named=True)
|
|
246
247
|
]
|
|
247
248
|
return UniqueResult(c.columns, c.duplicates, samples, c.target)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def seed_from_history(
|
|
252
|
+
tracker: UniqueTracker,
|
|
253
|
+
history: pl.LazyFrame,
|
|
254
|
+
batch: pl.DataFrame,
|
|
255
|
+
stored: Mapping[str, str],
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Seed `tracker` with the keys of `history` that `batch` also holds; `stored` maps runtime to history names.
|
|
258
|
+
|
|
259
|
+
A history key the batch lacks is never hit, so the flags are Rust's while memory follows the
|
|
260
|
+
batch: `history` is streamed, key columns only. The join matches -0.0 to 0.0 and NaN to any
|
|
261
|
+
NaN; the tracker then compares float bits, so those extra keys are never hit.
|
|
262
|
+
A key with a column `history` lacks (`add_columns` adds it on write) is skipped: historical
|
|
263
|
+
rows hold NULL there, which never duplicates.
|
|
264
|
+
"""
|
|
265
|
+
columns = set(history.collect_schema().names())
|
|
266
|
+
for key in tracker.keys:
|
|
267
|
+
names = [stored.get(c, c) for c in key]
|
|
268
|
+
if not columns.issuperset(names):
|
|
269
|
+
continue
|
|
270
|
+
# positional names (k0, b0, ...): no stored column name can collide with a temporary
|
|
271
|
+
ids = [f"k{i}" for i in range(len(key))]
|
|
272
|
+
distinct = (
|
|
273
|
+
# by_name: a column named like a pattern (`*`, `^...$`) is selected literally
|
|
274
|
+
batch.select(cs.by_name(c).alias(k) for c, k in zip(key, ids, strict=True))
|
|
275
|
+
.drop_nulls()
|
|
276
|
+
.unique()
|
|
277
|
+
)
|
|
278
|
+
if not distinct.height: # all-NULL keys are never checked; nothing to look up
|
|
279
|
+
continue
|
|
280
|
+
# distinct by float bits, as the tracker keys them: `unique` on a float merges -0.0 with 0.0
|
|
281
|
+
floats = [k for c, k in zip(key, ids, strict=True) if batch.schema[c].is_float()] # fmt: skip
|
|
282
|
+
bits = {f"b{k}": pl.col(k).map_batches(_float_bits, return_dtype=pl.UInt64, is_elementwise=True) for k in floats} # fmt: skip
|
|
283
|
+
subset = [k for k in ids if k not in floats] + list(bits)
|
|
284
|
+
scan = history.select(cs.by_name(n).alias(k) for n, k in zip(names, ids, strict=True)) # fmt: skip
|
|
285
|
+
# the history's dtypes can differ (unsigned widened, datetimes in us); a key its type
|
|
286
|
+
# cannot hold casts to NULL, which never matches
|
|
287
|
+
lookup = distinct.cast(scan.collect_schema(), strict=False).lazy()
|
|
288
|
+
hits = (
|
|
289
|
+
scan.join(lookup, on=ids, how="semi")
|
|
290
|
+
.with_columns(**bits)
|
|
291
|
+
# distinct keys only: memory follows the batch, not the history
|
|
292
|
+
.unique(subset)
|
|
293
|
+
.drop(*bits)
|
|
294
|
+
.collect(engine="streaming")
|
|
295
|
+
)
|
|
296
|
+
back = pl.Schema({k: batch.schema[c] for c, k in zip(key, ids, strict=True)})
|
|
297
|
+
tracker.seed(hits.cast(back).rename(dict(zip(ids, key, strict=True))))
|
floe/config/validate.py
CHANGED
|
@@ -4,7 +4,12 @@ import re
|
|
|
4
4
|
|
|
5
5
|
from floe import __version__
|
|
6
6
|
from floe.checks.cast import decimal_type
|
|
7
|
-
from floe.checks.normalize import
|
|
7
|
+
from floe.checks.normalize import (
|
|
8
|
+
normalize_name,
|
|
9
|
+
output_names,
|
|
10
|
+
resolve_strategy,
|
|
11
|
+
source_names,
|
|
12
|
+
)
|
|
8
13
|
from floe.config.project import (
|
|
9
14
|
LineageConfig,
|
|
10
15
|
RootConfig,
|
|
@@ -410,6 +415,10 @@ def validate_schema(entity: EntityConfig, version: str) -> None:
|
|
|
410
415
|
f"schema.normalize_columns.strategy={strategy} is unsupported "
|
|
411
416
|
f"(allowed: {', '.join(NORMALIZE_STRATEGIES)})",
|
|
412
417
|
)
|
|
418
|
+
try:
|
|
419
|
+
source_names(schema.columns, resolve_strategy(schema))
|
|
420
|
+
except FloeConfigError as err:
|
|
421
|
+
raise _fail(entity, str(err)) from err
|
|
413
422
|
mismatch = schema.mismatch
|
|
414
423
|
if mismatch is not None:
|
|
415
424
|
for field, policy, allowed in (
|
floe/iceberg/catalog.py
CHANGED
|
@@ -19,7 +19,7 @@ from pyiceberg.types import NestedField
|
|
|
19
19
|
|
|
20
20
|
from floe.config.storage import CatalogDefinition, StorageDefinition
|
|
21
21
|
from floe.config.types import SchemaEvolutionMode
|
|
22
|
-
from floe.errors import FloeConfigError, FloeRunError
|
|
22
|
+
from floe.errors import FloeConfigError, FloeError, FloeRunError
|
|
23
23
|
|
|
24
24
|
# (column, transform) pairs, as in the entity's `sink.accepted.partition_spec`;
|
|
25
25
|
# a bucket is `bucket[N]` (PyIceberg's notation) with N its `num_buckets` (D12)
|
|
@@ -44,6 +44,10 @@ def transform_name(field: PartitionField) -> str:
|
|
|
44
44
|
def run_error(context: str) -> Generator[None]:
|
|
45
45
|
try:
|
|
46
46
|
yield
|
|
47
|
+
except (
|
|
48
|
+
FloeError
|
|
49
|
+
): # already worded for the user, e.g. raised by the stream a write reads
|
|
50
|
+
raise
|
|
47
51
|
except Exception as e: # PyIceberg has no common base exception
|
|
48
52
|
raise FloeRunError(f"{context}: {e}") from e
|
|
49
53
|
|
|
@@ -308,10 +312,10 @@ def ensure_table(
|
|
|
308
312
|
partition_fields: PartitionFields | None = None,
|
|
309
313
|
schema_evolution: SchemaEvolutionMode = "strict",
|
|
310
314
|
create_namespace: bool = True,
|
|
311
|
-
) -> Table:
|
|
315
|
+
) -> tuple[Table, list[str]]:
|
|
312
316
|
"""Create the namespace (unless `create_namespace` is off) and table if missing; otherwise check the table matches (Rust's append path).
|
|
313
317
|
|
|
314
|
-
With `add_columns`, columns new in `schema` are added as optional (D13); partition changes stay rejected.
|
|
318
|
+
With `add_columns`, columns new in `schema` are added as optional (D13) and returned by name; partition changes stay rejected.
|
|
315
319
|
"""
|
|
316
320
|
ident = (namespace,)
|
|
317
321
|
with run_error("rest catalog namespace_exists check failed"):
|
|
@@ -348,16 +352,17 @@ def ensure_table(
|
|
|
348
352
|
for field in added:
|
|
349
353
|
update.add_column(field.name, field.field_type, required=False)
|
|
350
354
|
table.refresh()
|
|
351
|
-
return table
|
|
355
|
+
return table, [f.name for f in added]
|
|
352
356
|
spec = (
|
|
353
357
|
None
|
|
354
358
|
if partition_fields is None
|
|
355
359
|
else build_partition_spec(schema, partition_fields, entity)
|
|
356
360
|
)
|
|
357
361
|
with run_error("rest catalog create_table failed"):
|
|
358
|
-
|
|
362
|
+
created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
|
|
359
363
|
identifier,
|
|
360
364
|
schema,
|
|
361
365
|
location=location,
|
|
362
366
|
partition_spec=spec if spec is not None else PartitionSpec(),
|
|
363
367
|
)
|
|
368
|
+
return created, []
|
floe/iceberg/sink.py
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
8
|
import time
|
|
9
|
-
from collections.abc import Iterator, Sequence
|
|
9
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
10
10
|
from dataclasses import dataclass
|
|
11
|
+
from itertools import chain
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -112,23 +113,30 @@ def input_hash(entity: EntityConfig, files: Sequence[InputFile]) -> str:
|
|
|
112
113
|
_WIDER: dict[pl.DataType, pl.DataType] = {pl.UInt8(): pl.Int32(), pl.UInt16(): pl.Int32(), pl.UInt32(): pl.Int64()} # fmt: skip
|
|
113
114
|
|
|
114
115
|
|
|
115
|
-
def _reader(entity: EntityConfig, frames:
|
|
116
|
-
"""Output columns in schema order; `nullable: false` columns are required, as in Rust."""
|
|
116
|
+
def _reader(entity: EntityConfig, frames: Iterator[pl.DataFrame], names: list[str]) -> pa.RecordBatchReader: # fmt: skip
|
|
117
|
+
"""Output columns in schema order, from at least one frame; `nullable: false` columns are required, as in Rust."""
|
|
117
118
|
columns = entity.schema_.columns
|
|
118
|
-
for name, c in zip(names, columns, strict=True):
|
|
119
|
-
if c.nullable is False and any(f[name].null_count() for f in frames):
|
|
120
|
-
raise FloeRunError(f"iceberg write rejected nulls for non-nullable column {name}") # fmt: skip
|
|
121
119
|
|
|
122
120
|
def arrow(df: pl.DataFrame) -> pa.Table: # Iceberg has no unsigned types
|
|
123
121
|
return df.select(pl.col(n).cast(_WIDER.get(df.schema[n], df.schema[n])) for n in names).to_arrow() # fmt: skip
|
|
124
122
|
|
|
125
|
-
|
|
123
|
+
def checked(df: pl.DataFrame) -> pl.DataFrame:
|
|
124
|
+
for name, c in zip(names, columns, strict=True):
|
|
125
|
+
if c.nullable is False and df[name].null_count():
|
|
126
|
+
raise FloeRunError(f"iceberg write rejected nulls for non-nullable column {name}") # fmt: skip
|
|
127
|
+
return df
|
|
128
|
+
|
|
129
|
+
first = checked(
|
|
130
|
+
next(frames)
|
|
131
|
+
) # a violation in the first chunk fails before the table is created
|
|
132
|
+
schema = arrow(first.head(0)).schema
|
|
133
|
+
frames = chain([first], frames) # `batches` must not keep `first` alive
|
|
126
134
|
for i, c in enumerate(columns):
|
|
127
135
|
schema = schema.set(i, schema.field(i).with_nullable(c.nullable is not False))
|
|
128
136
|
|
|
129
137
|
def batches() -> Iterator[pa.RecordBatch]:
|
|
130
138
|
for df in frames:
|
|
131
|
-
yield from arrow(df).cast(schema).to_batches()
|
|
139
|
+
yield from arrow(checked(df)).cast(schema).to_batches()
|
|
132
140
|
|
|
133
141
|
return pa.RecordBatchReader.from_batches(schema, batches())
|
|
134
142
|
|
|
@@ -144,13 +152,13 @@ def partition_fields(entity: EntityConfig) -> PartitionFields | None:
|
|
|
144
152
|
def write_iceberg(
|
|
145
153
|
entity: EntityConfig,
|
|
146
154
|
target: IcebergTarget,
|
|
147
|
-
frames:
|
|
155
|
+
frames: Iterator[pl.DataFrame],
|
|
148
156
|
names: list[str],
|
|
149
157
|
mode: WriteMode,
|
|
150
158
|
run_id: str,
|
|
151
|
-
digest: str,
|
|
152
|
-
) -> tuple[IcebergWriteResult, dict[str, Any], dict[str, Any] | None]:
|
|
153
|
-
"""`frames` hold the output columns `names
|
|
159
|
+
digest: Callable[[], str],
|
|
160
|
+
) -> tuple[IcebergWriteResult, dict[str, Any], dict[str, Any] | None, list[str]]:
|
|
161
|
+
"""`frames` (at least one) hold the output columns `names` and are read while the data files are written; returns the result, its `accepted_output` table and merge fields, and the columns `add_columns` added."""
|
|
154
162
|
reader = _reader(entity, frames, names)
|
|
155
163
|
options = entity.sink.accepted.merge
|
|
156
164
|
scd2 = options.scd2 if options else None
|
|
@@ -175,7 +183,7 @@ def write_iceberg(
|
|
|
175
183
|
existing = set(catalog.load_table(identifier).schema().column_names)
|
|
176
184
|
if added := [k for k in key if k not in existing]: # Rust `merge/shared.rs`
|
|
177
185
|
raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
|
|
178
|
-
table = ensure_table(
|
|
186
|
+
table, added = ensure_table(
|
|
179
187
|
catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
|
|
180
188
|
partition_fields(entity), evolution.mode if evolution else "strict", target.catalog.create_database_if_missing,
|
|
181
189
|
) # fmt: skip
|
|
@@ -213,4 +221,4 @@ def write_iceberg(
|
|
|
213
221
|
"iceberg_namespace": target.namespace,
|
|
214
222
|
"iceberg_table": target.table,
|
|
215
223
|
}
|
|
216
|
-
return result, fields, merge
|
|
224
|
+
return result, fields, merge, added
|
floe/iceberg/unique.py
CHANGED
|
@@ -1,61 +1,27 @@
|
|
|
1
1
|
"""Unique keys already in an Iceberg table, for append (Rust `seed_unique_tracker_for_append`)."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import Mapping
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import polars as pl
|
|
7
|
+
import polars.selectors as cs
|
|
6
8
|
from pyiceberg.table import Table
|
|
7
9
|
|
|
8
|
-
from floe.checks.unique import (
|
|
9
|
-
UniqueTracker,
|
|
10
|
-
_float_bits, # pyright: ignore[reportPrivateUsage] -- the tracker's own float key
|
|
11
|
-
)
|
|
12
10
|
from floe.iceberg.catalog import run_error
|
|
13
11
|
|
|
14
12
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
stored: Mapping[str, str],
|
|
20
|
-
) -> None:
|
|
21
|
-
"""Seed `tracker` with the keys of `table` that `batch` also holds; `stored` maps runtime to table names.
|
|
13
|
+
def cache_table_keys(
|
|
14
|
+
table: Table, keys: Sequence[Sequence[str]], stored: Mapping[str, str], path: Path
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Write the key columns of `table` to `path` in one streamed scan; `stored` maps runtime to table names.
|
|
22
17
|
|
|
23
|
-
A
|
|
24
|
-
|
|
25
|
-
NaN; the tracker then compares float bits, so those extra keys are never hit.
|
|
26
|
-
A key with a column the table lacks (`add_columns` adds it on write) is skipped: historical
|
|
27
|
-
rows hold NULL there, which never duplicates.
|
|
18
|
+
A key with a column the table lacks (`add_columns` adds it on write) is left out: the tracker
|
|
19
|
+
skips it, as historical rows hold NULL there. False when no key is left, and nothing is written.
|
|
28
20
|
"""
|
|
29
|
-
|
|
30
|
-
for
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
distinct = (
|
|
37
|
-
batch.select(pl.col(c).alias(k) for c, k in zip(key, ids, strict=True))
|
|
38
|
-
.drop_nulls()
|
|
39
|
-
.unique()
|
|
40
|
-
)
|
|
41
|
-
if not distinct.height: # all-NULL keys are never checked; nothing to look up
|
|
42
|
-
continue
|
|
43
|
-
# distinct by float bits, as the tracker keys them: `unique` on a float merges -0.0 with 0.0
|
|
44
|
-
floats = [k for c, k in zip(key, ids, strict=True) if batch.schema[c].is_float()] # fmt: skip
|
|
45
|
-
bits = {f"b{k}": pl.col(k).map_batches(_float_bits, return_dtype=pl.UInt64, is_elementwise=True) for k in floats} # fmt: skip
|
|
46
|
-
subset = [k for k in ids if k not in floats] + list(bits)
|
|
47
|
-
with run_error("iceberg unique seed scan failed"):
|
|
48
|
-
scan = pl.scan_iceberg(table).select(pl.col(n).alias(k) for n, k in zip(names, ids, strict=True)) # fmt: skip
|
|
49
|
-
# the table's dtypes can differ (unsigned widened, datetimes in us); a key the table type
|
|
50
|
-
# cannot hold casts to NULL, which never matches
|
|
51
|
-
lookup = distinct.cast(scan.collect_schema(), strict=False).lazy()
|
|
52
|
-
hits = (
|
|
53
|
-
scan.join(lookup, on=ids, how="semi")
|
|
54
|
-
.with_columns(**bits)
|
|
55
|
-
# distinct keys only: memory follows the batch, not the history
|
|
56
|
-
.unique(subset)
|
|
57
|
-
.drop(*bits)
|
|
58
|
-
.collect(engine="streaming")
|
|
59
|
-
)
|
|
60
|
-
back = pl.Schema({k: batch.schema[c] for c, k in zip(key, ids, strict=True)})
|
|
61
|
-
tracker.seed(hits.cast(back).rename(dict(zip(ids, key, strict=True))))
|
|
21
|
+
present = set(table.schema().column_names)
|
|
22
|
+
names = {n for k in keys if present.issuperset(ns := [stored.get(c, c) for c in k]) for n in ns} # fmt: skip
|
|
23
|
+
if not names:
|
|
24
|
+
return False
|
|
25
|
+
with run_error("iceberg unique seed scan failed"):
|
|
26
|
+
pl.scan_iceberg(table).select(cs.by_name(sorted(names))).sink_parquet(path)
|
|
27
|
+
return True
|
floe/iceberg/write.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import itertools
|
|
7
7
|
import uuid
|
|
8
|
-
from collections.abc import Iterator, Sequence
|
|
8
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
9
9
|
from dataclasses import dataclass, replace
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from pathlib import PurePosixPath
|
|
@@ -82,7 +82,7 @@ def write_table(
|
|
|
82
82
|
reader: pa.RecordBatchReader,
|
|
83
83
|
mode: WriteMode,
|
|
84
84
|
run_id: str,
|
|
85
|
-
input_hash: str,
|
|
85
|
+
input_hash: str | Callable[[], str],
|
|
86
86
|
small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
|
|
87
87
|
chunk_rows: int = CHUNK_ROWS,
|
|
88
88
|
unique_keys: Sequence[Sequence[str]] = (),
|
|
@@ -104,6 +104,9 @@ def write_table(
|
|
|
104
104
|
table.metadata, chunk, table.io, write_uuid, counter
|
|
105
105
|
)
|
|
106
106
|
]
|
|
107
|
+
digest = (
|
|
108
|
+
input_hash() if callable(input_hash) else input_hash
|
|
109
|
+
) # known once the stream has been read
|
|
107
110
|
base = (
|
|
108
111
|
table.metadata.current_snapshot_id
|
|
109
112
|
) # only commits after it can be a rival's copy of this batch
|
|
@@ -112,9 +115,7 @@ def write_table(
|
|
|
112
115
|
_backoff(attempt - 1)
|
|
113
116
|
with run_error("rest iceberg table refresh failed"):
|
|
114
117
|
table.refresh()
|
|
115
|
-
seen = mode == "append" and find_committed(
|
|
116
|
-
table, input_hash, after=base
|
|
117
|
-
)
|
|
118
|
+
seen = mode == "append" and find_committed(table, digest, after=base)
|
|
118
119
|
if seen:
|
|
119
120
|
return replace(write_result(table, [], small_file_threshold_bytes), already_committed=True) # fmt: skip
|
|
120
121
|
if mode == "append":
|
|
@@ -131,7 +132,7 @@ def write_table(
|
|
|
131
132
|
table.metadata = table.metadata.model_copy(update={"properties": {**table.properties, TableProperties.COMMIT_NUM_RETRIES: "0"}}) # fmt: skip
|
|
132
133
|
tx = table.transaction()
|
|
133
134
|
update = tx.update_snapshot(
|
|
134
|
-
snapshot_properties={RUN_ID: run_id, INPUT_HASH:
|
|
135
|
+
snapshot_properties={RUN_ID: run_id, INPUT_HASH: digest}
|
|
135
136
|
)
|
|
136
137
|
with (
|
|
137
138
|
update.overwrite()
|
floe/report/entity.py
CHANGED
|
@@ -157,6 +157,7 @@ def build_report(
|
|
|
157
157
|
table_root_uri: str | None = None,
|
|
158
158
|
table: Mapping[str, Any] | None = None,
|
|
159
159
|
merge: Mapping[str, Any] | None = None,
|
|
160
|
+
added_columns: Sequence[str] = (),
|
|
160
161
|
) -> dict[str, Any]:
|
|
161
162
|
"""The entity's `run.json`; `files` are the `file_report`s, in input order.
|
|
162
163
|
|
|
@@ -234,9 +235,10 @@ def build_report(
|
|
|
234
235
|
**(merge or {}),
|
|
235
236
|
},
|
|
236
237
|
"schema_evolution": {
|
|
237
|
-
"enabled":
|
|
238
|
+
"enabled": evolution.mode == "add_columns",
|
|
238
239
|
"mode": evolution.mode,
|
|
239
|
-
"applied":
|
|
240
|
+
"applied": bool(added_columns),
|
|
241
|
+
**({"added_columns": list(added_columns)} if added_columns else {}),
|
|
240
242
|
"incompatible_changes_detected": False,
|
|
241
243
|
},
|
|
242
244
|
} # fmt: skip
|
floe/run/entity.py
CHANGED
|
@@ -4,7 +4,7 @@ import io
|
|
|
4
4
|
import tempfile
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
|
-
from collections.abc import Callable, Iterator
|
|
7
|
+
from collections.abc import Callable, Generator, Iterable, Iterator
|
|
8
8
|
from contextlib import suppress
|
|
9
9
|
from dataclasses import dataclass, field, replace
|
|
10
10
|
from datetime import datetime
|
|
@@ -39,7 +39,12 @@ from floe.checks.rows import (
|
|
|
39
39
|
not_null_check,
|
|
40
40
|
split_rows,
|
|
41
41
|
)
|
|
42
|
-
from floe.checks.unique import
|
|
42
|
+
from floe.checks.unique import (
|
|
43
|
+
UniqueResult,
|
|
44
|
+
UniqueTracker,
|
|
45
|
+
resolve_unique_keys,
|
|
46
|
+
seed_from_history,
|
|
47
|
+
)
|
|
43
48
|
from floe.config.storage import CatalogsConfig, StorageDefinition, StoragesConfig
|
|
44
49
|
from floe.config.types import EntityConfig, with_partition_columns
|
|
45
50
|
from floe.errors import FloeConfigError, FloeIoError, FloeRunError
|
|
@@ -49,7 +54,7 @@ from floe.iceberg.sink import (
|
|
|
49
54
|
resolve_target,
|
|
50
55
|
write_iceberg,
|
|
51
56
|
)
|
|
52
|
-
from floe.iceberg.unique import
|
|
57
|
+
from floe.iceberg.unique import cache_table_keys
|
|
53
58
|
from floe.iceberg.write import find_committed
|
|
54
59
|
from floe.pii import apply_pii_masking
|
|
55
60
|
from floe.read.avro import read_avro
|
|
@@ -253,6 +258,9 @@ class _Plan:
|
|
|
253
258
|
table: Table | None = (
|
|
254
259
|
None # the Iceberg target whose keys seed the tracker (append)
|
|
255
260
|
)
|
|
261
|
+
history: pl.LazyFrame | None = (
|
|
262
|
+
None # the target's key columns, cached on local disk (append, #1057)
|
|
263
|
+
)
|
|
256
264
|
merge_key: str | None = (
|
|
257
265
|
None # the merge key's unique check; its duplicates are rejected even under warn (Rust `enforce_reject`)
|
|
258
266
|
)
|
|
@@ -354,14 +362,29 @@ def _typed(
|
|
|
354
362
|
if entity.policy.severity == "abort" and any(c.mask.any() for c in cast_checks):
|
|
355
363
|
return typed, cast_checks # fmt: skip # Rust: abort-mode cast errors skip not_null and unique
|
|
356
364
|
required = [not_null_check(typed, n) for n in plan.required]
|
|
357
|
-
if plan.
|
|
358
|
-
|
|
359
|
-
seed_from_table(plan.tracker, plan.table, typed, plan.out_map)
|
|
365
|
+
if plan.history is not None:
|
|
366
|
+
seed_from_history(plan.tracker, plan.history, typed, plan.out_map)
|
|
360
367
|
return typed, [*required, *cast_checks, *plan.tracker.check(typed)]
|
|
361
368
|
|
|
362
369
|
|
|
363
|
-
def
|
|
364
|
-
|
|
370
|
+
def _unspill(path: Path) -> pl.DataFrame:
|
|
371
|
+
df = pl.read_ipc(path)
|
|
372
|
+
path.unlink()
|
|
373
|
+
return df
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _replay(plan: _Plan, held: list[pl.DataFrame | Path]) -> Iterator[pl.DataFrame]:
|
|
377
|
+
while held:
|
|
378
|
+
item = held.pop(0)
|
|
379
|
+
yield _output(plan, item if isinstance(item, pl.DataFrame) else _unspill(item))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
|
|
383
|
+
"""The file's report and its accepted frames, one by one (none when it aborted the run); `path` is the local copy.
|
|
384
|
+
|
|
385
|
+
A file's rows are accepted only once it is read to the end (a late read error or an abort drops them all), so
|
|
386
|
+
every chunk after the first waits in `spill`, not in memory.
|
|
387
|
+
"""
|
|
365
388
|
entity, uri = plan.entity, file.source_uri
|
|
366
389
|
severity = entity.policy.severity
|
|
367
390
|
derived = plan.derived.get(uri, [])
|
|
@@ -372,7 +395,7 @@ def _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any],
|
|
|
372
395
|
chunks = _chunks(entity, path, file_names)
|
|
373
396
|
keys = plan.tracker.snapshot()
|
|
374
397
|
|
|
375
|
-
def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any],
|
|
398
|
+
def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
|
|
376
399
|
# Rust skips the raw copy under PII: the file cannot be masked
|
|
377
400
|
plan.tracker.restore(keys) # the unreadable file leaves no keys behind
|
|
378
401
|
keep = entity.pii is None and entity.sink.rejected is not None
|
|
@@ -403,7 +426,7 @@ def _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any],
|
|
|
403
426
|
|
|
404
427
|
rows, bad_total, forced_total = 0, 0, 0
|
|
405
428
|
masks: dict[tuple[str, str, str], list[pl.Series]] = {}
|
|
406
|
-
accepted: list[pl.DataFrame] = []
|
|
429
|
+
accepted: list[pl.DataFrame | Path] = []
|
|
407
430
|
rejected: list[pl.DataFrame] = []
|
|
408
431
|
errors: list[tuple[int, str]] = []
|
|
409
432
|
try:
|
|
@@ -423,7 +446,12 @@ def _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any],
|
|
|
423
446
|
forced = forced.fill_null(False)
|
|
424
447
|
forced_total += int(forced.sum())
|
|
425
448
|
typed = typed.filter(~forced)
|
|
426
|
-
|
|
449
|
+
frame = good if severity == "reject" else typed
|
|
450
|
+
if accepted:
|
|
451
|
+
frame.write_ipc(spilled := spill / f"{len(accepted)}.ipc")
|
|
452
|
+
accepted.append(spilled)
|
|
453
|
+
else:
|
|
454
|
+
accepted.append(frame)
|
|
427
455
|
if severity == "reject" and bad.height:
|
|
428
456
|
rejected.append(bad)
|
|
429
457
|
except FloeIoError as err:
|
|
@@ -445,22 +473,30 @@ def _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any],
|
|
|
445
473
|
errors_path = _error_report(plan, file.source_stem, errors)
|
|
446
474
|
plan.to_archive.append(file)
|
|
447
475
|
out = file_report(entity, uri, rows, bad_total, merged, mismatch, rejected_path, None, errors_path, forced_total) # fmt: skip
|
|
448
|
-
return out, [] if out["status"] == "aborted" else
|
|
476
|
+
return out, [] if out["status"] == "aborted" else _replay(plan, accepted)
|
|
477
|
+
|
|
449
478
|
|
|
479
|
+
def _key_cache(plan: _Plan, client: StorageClient, base: str, temp: Path) -> pl.LazyFrame | None: # fmt: skip
|
|
480
|
+
"""Append mode: the key columns of the target's existing data, written once to local disk (#1057).
|
|
450
481
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
482
|
+
Each chunk then joins its keys against this cache, so the target is read once per entity and
|
|
483
|
+
memory follows the chunk, not the history (Rust `seed_unique_tracker_for_append`).
|
|
484
|
+
"""
|
|
485
|
+
cache = temp / "unique-keys"
|
|
486
|
+
cache.mkdir()
|
|
487
|
+
if plan.table is not None:
|
|
488
|
+
cache_table_keys(plan.table, plan.tracker.keys, plan.out_map, cache / "table.parquet") # fmt: skip
|
|
489
|
+
else:
|
|
490
|
+
columns = sorted({plan.out_map.get(n, n) for k in plan.tracker.keys for n in k}) # the sink holds output names # fmt: skip
|
|
491
|
+
for i, uri in enumerate(part_uris(client, base.rstrip("/"), "parquet")):
|
|
492
|
+
part = uri.removeprefix("local://")
|
|
493
|
+
try: # only the key columns are read
|
|
494
|
+
body = client.read(uri)
|
|
495
|
+
keys = pl.read_parquet(io.BytesIO(body.body if body else b""), columns=columns) # fmt: skip
|
|
496
|
+
except pl.exceptions.PolarsError as err:
|
|
497
|
+
raise FloeRunError(f"entity.name={plan.entity.name} cannot seed unique keys from {part}: {err}") from err # fmt: skip
|
|
498
|
+
keys.write_parquet(cache / f"{i}.parquet")
|
|
499
|
+
return pl.scan_parquet(cache / "*.parquet") if any(cache.iterdir()) else None
|
|
464
500
|
|
|
465
501
|
|
|
466
502
|
def _report_names(
|
|
@@ -855,14 +891,7 @@ def _run_entity(
|
|
|
855
891
|
pk[0] if len(pk) == 1 else f"[{','.join(pk)}]"
|
|
856
892
|
) # the tracker's check name
|
|
857
893
|
accepted_client = storage_client(accepted)
|
|
858
|
-
if sink.write_mode == "append" and target is None:
|
|
859
|
-
_seed_unique(
|
|
860
|
-
plan,
|
|
861
|
-
accepted_client,
|
|
862
|
-
base,
|
|
863
|
-
sorted({runtime.get(c, c) for k in keys for c in k}),
|
|
864
|
-
)
|
|
865
|
-
elif sink.write_mode == "append" and target is not None:
|
|
894
|
+
if sink.write_mode == "append" and target is not None:
|
|
866
895
|
plan.table = existing_table(target) # fmt: skip # Rust `seed_unique_tracker`, and the retry guard
|
|
867
896
|
|
|
868
897
|
def source() -> ResolvedInputs:
|
|
@@ -894,52 +923,73 @@ def _run_entity(
|
|
|
894
923
|
plan.to_archive += [f for f in pending if f.source_uri in skip]
|
|
895
924
|
pending = [f for f in pending if f.source_uri not in skip]
|
|
896
925
|
plan.input_hash = input_hash(entity, pending)
|
|
897
|
-
frames: list[pl.DataFrame] = []
|
|
898
926
|
# #545: the snapshot's fingerprint names only the files it holds in full, never an aborted batch
|
|
899
927
|
complete: list[InputFile] = []
|
|
900
928
|
skipped = len(files)
|
|
901
929
|
downloader = None if isinstance(client, LocalClient) else client
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
930
|
+
failure: list[Exception] = []
|
|
931
|
+
|
|
932
|
+
def accepted_frames() -> Generator[pl.DataFrame]:
|
|
933
|
+
"""Files are read as the sink pulls (#1056); `files` and `complete` are final once it is exhausted."""
|
|
934
|
+
try:
|
|
935
|
+
# Rust `localize_input`: a remote input is read from a local copy
|
|
936
|
+
with tempfile.TemporaryDirectory(prefix="floe-") as temp_dir:
|
|
937
|
+
if pending and sink.write_mode == "append" and plan.tracker.keys and (target is None or plan.table is not None): # fmt: skip
|
|
938
|
+
plan.history = _key_cache(plan, accepted_client, base, Path(temp_dir)) # fmt: skip
|
|
939
|
+
for f in pending:
|
|
940
|
+
emit("file_started", entity=entity.name, input=f.source_uri)
|
|
941
|
+
timer = time.monotonic()
|
|
942
|
+
local = localize_input(f, downloader, Path(temp_dir))
|
|
943
|
+
report, kept = _run_file(plan, f, local.local_path, Path(temp_dir))
|
|
944
|
+
if local.is_ephemeral:
|
|
945
|
+
local.local_path.unlink()
|
|
946
|
+
emit("file_finished", entity=entity.name, input=f.source_uri, status=report["status"],
|
|
947
|
+
rows=report["row_count"], accepted=report["accepted_count"],
|
|
948
|
+
rejected=report["rejected_count"], elapsed_ms=int((time.monotonic() - timer) * 1000)) # fmt: skip
|
|
949
|
+
files.append(report)
|
|
950
|
+
yield from kept
|
|
951
|
+
if report["status"] == "aborted":
|
|
952
|
+
break
|
|
953
|
+
complete.append(f)
|
|
954
|
+
except Exception as err:
|
|
955
|
+
# the sinks wrap what they read; this error reaches the caller as it is
|
|
956
|
+
failure.append(err)
|
|
957
|
+
raise
|
|
920
958
|
|
|
921
959
|
table: dict[str, Any] | None = None
|
|
922
960
|
merge: dict[str, Any] | None = None
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
961
|
+
added: list[str] = []
|
|
962
|
+
stream = accepted_frames()
|
|
963
|
+
try:
|
|
964
|
+
first = next(stream, None) # Rust writes nothing when every input was skipped
|
|
965
|
+
if pending and (first is not None or sink.write_mode == "overwrite"):
|
|
966
|
+
empty = pl.DataFrame(schema=dict.fromkeys(names, pl.String)).select(
|
|
967
|
+
cast_column(n, c.column_type)[0]
|
|
968
|
+
for n, c in zip(names, columns, strict=True)
|
|
969
|
+
)
|
|
970
|
+
frames = chain([_output(plan, empty) if first is None else first], stream)
|
|
971
|
+
del first # the sink holds the chunk it is on, not the first
|
|
972
|
+
if target is None:
|
|
973
|
+
write = write_accepted(frames, accepted_client, base, sink.write_mode, sink.accepted.options) # fmt: skip
|
|
974
|
+
else:
|
|
975
|
+
outputs = [plan.out_map.get(n, n) for n in names]
|
|
976
|
+
result, table, merge, added = write_iceberg(entity, target, frames, outputs, sink.write_mode, emit.run_id, lambda: input_hash(entity, complete)) # fmt: skip
|
|
977
|
+
write = AcceptedWrite(result.files_written, result.file_paths, result.total_bytes_written,
|
|
978
|
+
result.avg_file_size_mb, result.small_files_count) # fmt: skip
|
|
979
|
+
if added:
|
|
980
|
+
emit("schema_evolution_applied", entity=entity.name, mode="add_columns", added_columns=added) # fmt: skip
|
|
981
|
+
if result.already_committed: # #920: the commit conflicted and the guard found this batch in the table
|
|
982
|
+
done = {f.source_uri for f in complete}
|
|
983
|
+
files[:] = [skipped_report(r["input_file"], "already_committed") if r["input_file"] in done else r for r in files] # fmt: skip
|
|
933
984
|
else:
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
write = AcceptedWrite(0, [], 0, 0.0, 0)
|
|
985
|
+
write = AcceptedWrite(0, [], 0, 0.0, 0)
|
|
986
|
+
except Exception:
|
|
987
|
+
if not failure:
|
|
988
|
+
raise
|
|
989
|
+
raise failure[0] from failure[0].__cause__
|
|
990
|
+
finally:
|
|
991
|
+
stream.close()
|
|
992
|
+
files[skipped:] = sorted(files[skipped:], key=lambda r: r["input_file"] not in plan.prechecked) # fmt: skip
|
|
943
993
|
inc.committed = files # #915: from here on a failure promotes the written files
|
|
944
994
|
# #914: archive only once the sink committed, so a failed commit leaves the inputs in the source (Rust archives before)
|
|
945
995
|
by_uri = {r["input_file"]: r for r in files}
|
|
@@ -959,5 +1009,6 @@ def _run_entity(
|
|
|
959
1009
|
table_root_uri=target.location if target and table else None,
|
|
960
1010
|
table=table,
|
|
961
1011
|
merge=merge,
|
|
1012
|
+
added_columns=added,
|
|
962
1013
|
)
|
|
963
1014
|
return EntityRun(report, any(f["status"] == "aborted" for f in files))
|
floe/write/outputs.py
CHANGED
|
@@ -4,7 +4,7 @@ import io
|
|
|
4
4
|
import math
|
|
5
5
|
import re
|
|
6
6
|
import uuid
|
|
7
|
-
from collections.abc import Callable, Generator
|
|
7
|
+
from collections.abc import Callable, Generator, Iterable, Iterator
|
|
8
8
|
from contextlib import contextmanager, suppress
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
|
|
@@ -59,38 +59,32 @@ def part_uris(client: StorageClient, base: str, ext: str) -> list[str]:
|
|
|
59
59
|
|
|
60
60
|
@contextmanager
|
|
61
61
|
def _parts(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Generator[Callable[[bytes], str]]: # fmt: skip
|
|
62
|
-
"""Yield `put(body) -> name`.
|
|
62
|
+
"""Yield `put(body) -> name`. Every part is staged as `_staging-<id>-<name>` (invisible to `part_uris`).
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Only when all were written are they copied over `<name>` and the staged copies deleted (#1054).
|
|
65
|
+
Overwrite names the parts `part-NNNNN` and deletes the old parts that no new part replaced, so a
|
|
66
|
+
failed write leaves them untouched. Other modes name them `part-<uuid>` and keep the old parts;
|
|
67
|
+
staging keeps a killed run from leaving visible parts for a retry to duplicate (#1056: parts are
|
|
68
|
+
written while the input is still read). A failed write deletes the staged parts it wrote.
|
|
67
69
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def put(body: bytes) -> str:
|
|
71
|
-
name = f"part-{uuid.uuid4()}.{ext}"
|
|
72
|
-
client.write(f"{base}/{name}", body)
|
|
73
|
-
return name
|
|
74
|
-
|
|
75
|
-
yield put
|
|
76
|
-
return
|
|
70
|
+
overwrite = mode == "overwrite"
|
|
77
71
|
stage = f"_staging-{uuid.uuid4()}-"
|
|
78
72
|
names: list[str] = []
|
|
79
73
|
|
|
80
|
-
def
|
|
81
|
-
name = f"part-{len(names):05}.{ext}"
|
|
74
|
+
def put(body: bytes) -> str:
|
|
75
|
+
name = f"part-{len(names):05}.{ext}" if overwrite else f"part-{uuid.uuid4()}.{ext}" # fmt: skip
|
|
82
76
|
names.append(name) # before the write: a partial file needs cleanup too
|
|
83
77
|
client.write(f"{base}/{stage}{name}", body)
|
|
84
78
|
return name
|
|
85
79
|
|
|
86
80
|
try:
|
|
87
|
-
yield
|
|
81
|
+
yield put
|
|
88
82
|
except BaseException:
|
|
89
83
|
for name in names:
|
|
90
84
|
with suppress(FloeError, OSError): # best effort: the original error wins
|
|
91
85
|
client.delete(f"{base}/{stage}{name}")
|
|
92
86
|
raise
|
|
93
|
-
old = part_uris(client, base, ext)
|
|
87
|
+
old = part_uris(client, base, ext) if overwrite else []
|
|
94
88
|
for name in names:
|
|
95
89
|
client.copy(f"{base}/{stage}{name}", f"{base}/{name}")
|
|
96
90
|
for uri in set(old) - {f"{base}/{n}" for n in names}:
|
|
@@ -99,18 +93,38 @@ def _parts(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Gener
|
|
|
99
93
|
client.delete(f"{base}/{stage}{name}")
|
|
100
94
|
|
|
101
95
|
|
|
96
|
+
def _part_frames(
|
|
97
|
+
frames: Iterable[pl.DataFrame], max_size: int
|
|
98
|
+
) -> Iterator[pl.DataFrame]:
|
|
99
|
+
"""`frames` regrouped into parts of about `max_size` bytes in memory; at most one part and a frame are pooled."""
|
|
100
|
+
pool: list[pl.DataFrame] = []
|
|
101
|
+
size = height = 0
|
|
102
|
+
for df in frames:
|
|
103
|
+
pool.append(df)
|
|
104
|
+
size += df.estimated_size()
|
|
105
|
+
height += df.height
|
|
106
|
+
rows = max(1, max_size // max(1, math.ceil(size / height))) if height else 1
|
|
107
|
+
if height >= rows:
|
|
108
|
+
whole = pl.concat(pool)
|
|
109
|
+
full = height - height % rows
|
|
110
|
+
yield from (whole.slice(i, rows) for i in range(0, full, rows))
|
|
111
|
+
rest = whole.slice(full)
|
|
112
|
+
pool = [rest] if rest.height else []
|
|
113
|
+
size, height = rest.estimated_size(), rest.height
|
|
114
|
+
if pool: # also the one empty part of a run that accepted no row
|
|
115
|
+
yield pl.concat(pool)
|
|
116
|
+
|
|
117
|
+
|
|
102
118
|
def write_accepted(
|
|
103
|
-
|
|
119
|
+
frames: Iterable[pl.DataFrame],
|
|
104
120
|
client: StorageClient,
|
|
105
121
|
base: str,
|
|
106
122
|
mode: WriteMode = "overwrite",
|
|
107
123
|
options: SinkOptions | None = None,
|
|
108
124
|
) -> AcceptedWrite:
|
|
109
|
-
"""Write `
|
|
125
|
+
"""Write `frames` (at least one) as `part-*.parquet` files under `base`, each holding about `max_size_per_file`."""
|
|
110
126
|
base = base.rstrip("/")
|
|
111
127
|
max_size = (options and options.max_size_per_file) or DEFAULT_MAX_SIZE_PER_FILE
|
|
112
|
-
avg_row = max(1, math.ceil(df.estimated_size() / df.height)) if df.height else 1
|
|
113
|
-
rows = max(1, max_size // avg_row)
|
|
114
128
|
kwargs: dict[str, str | int] = {}
|
|
115
129
|
if options and options.compression:
|
|
116
130
|
kwargs["compression"] = options.compression
|
|
@@ -119,7 +133,7 @@ def write_accepted(
|
|
|
119
133
|
files: list[str] = []
|
|
120
134
|
sizes: list[int] = []
|
|
121
135
|
with _os_errors(), _parts(client, base, mode, "parquet") as put:
|
|
122
|
-
for chunk in
|
|
136
|
+
for chunk in _part_frames(frames, max_size):
|
|
123
137
|
# ponytail: a part is held in memory (≤ max_size_per_file) before the put; stream multipart uploads if that is too much
|
|
124
138
|
body = io.BytesIO()
|
|
125
139
|
chunk.write_parquet(body, **kwargs) # pyright: ignore[reportArgumentType] # ponytail: kwargs loses the compression Literal; validated by the config
|
|
@@ -13,9 +13,9 @@ floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
13
13
|
floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
|
|
14
14
|
floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
|
|
15
15
|
floe/checks/mismatch.py,sha256=2Gz-tiWKxy-iehSjAlsGzPLtYeNIfBWvi4yQPlASVYQ,5309
|
|
16
|
-
floe/checks/normalize.py,sha256=
|
|
16
|
+
floe/checks/normalize.py,sha256=8ZfpGFauFLIrq_xHY3iIe-rtuKO88YACDVO0WQJFWCU,4321
|
|
17
17
|
floe/checks/rows.py,sha256=E-YUZUvBUyOB7zat2dtbPWFM-0T1-01lYKuQU1A6wto,4156
|
|
18
|
-
floe/checks/unique.py,sha256=
|
|
18
|
+
floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
|
|
19
19
|
floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
|
|
21
21
|
floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
|
|
@@ -24,15 +24,15 @@ floe/config/profile.py,sha256=jWackztS-0H3dTMXHALTZ7LsIOmwjriwoeY4vFgARr0,16046
|
|
|
24
24
|
floe/config/project.py,sha256=zgTOhTDlbMr1chViMEZyv0drozZdumTaIoKDL0vv9sw,14141
|
|
25
25
|
floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
|
|
26
26
|
floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
|
|
27
|
-
floe/config/validate.py,sha256
|
|
27
|
+
floe/config/validate.py,sha256=4lU6yJ1qpI8AIr42SGy-xJR2brz_DBG6pNS_dkZw4zo,36065
|
|
28
28
|
floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
|
|
29
29
|
floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
|
|
30
|
-
floe/iceberg/catalog.py,sha256=
|
|
30
|
+
floe/iceberg/catalog.py,sha256=n2NlgqRgnTe3Etdyra70Ib2aYtpR7zXWrajKl7spyYg,15340
|
|
31
31
|
floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
|
-
floe/iceberg/sink.py,sha256=
|
|
34
|
-
floe/iceberg/unique.py,sha256=
|
|
35
|
-
floe/iceberg/write.py,sha256=
|
|
33
|
+
floe/iceberg/sink.py,sha256=ySEAsKYpaKnqF9t7WjOW03KwpKynNIqLxOP1kQ-0Tbg,11723
|
|
34
|
+
floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
|
|
35
|
+
floe/iceberg/write.py,sha256=SRX5tb17zUeWwpg6uXKkVQ_muNJ2GqlQd0n-kmjWPVc,11141
|
|
36
36
|
floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
|
|
37
37
|
floe/lineage/observer.py,sha256=dDYfWvPpFGjdqvFqQDZ2pTjj7uDfqji2-BTID9hzg90,10075
|
|
38
38
|
floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -43,10 +43,10 @@ floe/read/orc.py,sha256=TWgdlLBR2NF_oVZ5zEmRKNXoTkq5QfF2BOFzKyzSjhY,1553
|
|
|
43
43
|
floe/read/parquet.py,sha256=JwfuWaHj2TxBKJeaKignq8p0Op_pUfsOdVh7dDC3wcE,1491
|
|
44
44
|
floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
|
|
45
45
|
floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
|
|
46
|
-
floe/report/entity.py,sha256=
|
|
46
|
+
floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
|
|
47
47
|
floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
|
|
48
48
|
floe/run/__init__.py,sha256=6teCk4GZoX7grwtyhQUbqwbx_TSQebzViUJA19CmEl4,8107
|
|
49
|
-
floe/run/entity.py,sha256=
|
|
49
|
+
floe/run/entity.py,sha256=dML8VR-UyLUNVupkKaHeFIbR06TdPP2qcJ2rAMW4GeU,47568
|
|
50
50
|
floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
|
|
51
51
|
floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
52
|
floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
|
|
@@ -58,9 +58,9 @@ floe/storage/resolve.py,sha256=pFAuVytTfnSEcTAVlJjFTj7S_Vipjry1ormWZP3Egmc,2593
|
|
|
58
58
|
floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
|
|
59
59
|
floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
|
|
60
60
|
floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
61
|
-
floe/write/outputs.py,sha256=
|
|
62
|
-
floe_python-0.8.
|
|
63
|
-
floe_python-0.8.
|
|
64
|
-
floe_python-0.8.
|
|
65
|
-
floe_python-0.8.
|
|
66
|
-
floe_python-0.8.
|
|
61
|
+
floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
|
|
62
|
+
floe_python-0.8.4.dist-info/METADATA,sha256=kGo2pIkok-Gpky0VSm-2S5ttVTHx3tDcE21ynZE-HM4,442
|
|
63
|
+
floe_python-0.8.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
64
|
+
floe_python-0.8.4.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
65
|
+
floe_python-0.8.4.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
66
|
+
floe_python-0.8.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|