floe-python 0.8.3__py3-none-any.whl → 0.8.5__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 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 {column.name} -> {name}"
79
+ f"column source collision: {seen[name]} and {source} -> {name}"
80
80
  )
81
81
  seen[name] = source
82
82
  return list(seen)
@@ -94,12 +94,12 @@ def output_column_mapping(
94
94
  targets: dict[str, str] = {}
95
95
  mapping: dict[str, str] = {}
96
96
  for column, target in zip(columns, output_names(columns, strategy), strict=True):
97
- source = normalize_name(column.source or column.name, strategy)
98
97
  if target in targets:
99
98
  raise FloeConfigError(
100
- f"output column name collision: {targets[target]} and {source} -> {target}"
99
+ f"output column name collision: {targets[target]} and {column.name} -> {target}"
101
100
  )
102
- targets[target] = source
101
+ targets[target] = column.name
102
+ source = normalize_name(column.source or column.name, strategy)
103
103
  if source != target:
104
104
  mapping[source] = target
105
105
  return mapping
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,13 @@ 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 normalize_name, output_names
7
+ from floe.checks.normalize import (
8
+ normalize_name,
9
+ output_column_mapping,
10
+ output_names,
11
+ resolve_strategy,
12
+ source_names,
13
+ )
8
14
  from floe.config.project import (
9
15
  LineageConfig,
10
16
  RootConfig,
@@ -410,6 +416,12 @@ def validate_schema(entity: EntityConfig, version: str) -> None:
410
416
  f"schema.normalize_columns.strategy={strategy} is unsupported "
411
417
  f"(allowed: {', '.join(NORMALIZE_STRATEGIES)})",
412
418
  )
419
+ try:
420
+ resolved = resolve_strategy(schema)
421
+ source_names(schema.columns, resolved)
422
+ output_column_mapping(schema.columns, resolved)
423
+ except FloeConfigError as err:
424
+ raise _fail(entity, str(err)) from err
413
425
  mismatch = schema.mismatch
414
426
  if mismatch is not None:
415
427
  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
- return catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
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: list[pl.DataFrame], names: list[str]) -> pa.RecordBatchReader: # fmt: skip
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
- schema = arrow(frames[0].head(0)).schema
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: list[pl.DataFrame],
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`; returns the result, its `accepted_output` table and merge fields."""
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 seed_from_table(
16
- tracker: UniqueTracker,
17
- table: Table,
18
- batch: pl.DataFrame,
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 target key the batch lacks is never hit, so the flags are Rust's while memory follows the
24
- batch: the table is streamed, key columns only. The join matches -0.0 to 0.0 and NaN to any
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
- columns = set(table.schema().column_names)
30
- for key in tracker.keys:
31
- names = [stored.get(c, c) for c in key]
32
- if not columns.issuperset(names):
33
- continue
34
- # positional names (k0, b0, ...): no stored column name can collide with a temporary
35
- ids = [f"k{i}" for i in range(len(key))]
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: 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": False,
238
+ "enabled": evolution.mode == "add_columns",
238
239
  "mode": evolution.mode,
239
- "applied": False,
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 UniqueResult, UniqueTracker, resolve_unique_keys
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 seed_from_table
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.table is not None:
358
- # ponytail: one key scan of the table per chunk; a DuckDB anti-join (ADR) if large histories make it slow
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 _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any], list[pl.DataFrame]]: # fmt: skip
364
- """The file's report and its accepted frames (none when it aborted the run); `path` is the local copy."""
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], list[pl.DataFrame]]: # fmt: skip
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
- accepted.append(good if severity == "reject" else typed)
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 [_output(plan, df) for df in accepted] # fmt: skip
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
- def _seed_unique(plan: _Plan, client: StorageClient, base: str, columns: list[str]) -> None: # fmt: skip
452
- """Append mode: the keys already in the sink's parts count as seen (Rust `seed_unique_tracker_for_append`)."""
453
- if not columns:
454
- return # no unique keys: never read the history
455
- stored = {n: plan.out_map.get(n, n) for n in columns} # the sink holds output names
456
- for uri in part_uris(client, base.rstrip("/"), "parquet"):
457
- part = uri.removeprefix("local://")
458
- try: # only the key columns are read
459
- body = client.read(uri)
460
- keys = pl.read_parquet(io.BytesIO(body.body if body else b""), columns=list(stored.values())) # fmt: skip
461
- except pl.exceptions.PolarsError as err:
462
- raise FloeRunError(f"entity.name={plan.entity.name} cannot seed unique keys from {part}: {err}") from err # fmt: skip
463
- plan.tracker.seed(keys.rename({v: k for k, v in stored.items()}))
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
- # Rust `localize_input`: a remote input is read from a local copy
903
- with tempfile.TemporaryDirectory(prefix="floe-") as temp_dir:
904
- for f in pending:
905
- emit("file_started", entity=entity.name, input=f.source_uri)
906
- timer = time.monotonic()
907
- local = localize_input(f, downloader, Path(temp_dir))
908
- report, kept = _run_file(plan, f, local.local_path)
909
- if local.is_ephemeral:
910
- local.local_path.unlink()
911
- emit("file_finished", entity=entity.name, input=f.source_uri, status=report["status"],
912
- rows=report["row_count"], accepted=report["accepted_count"],
913
- rejected=report["rejected_count"], elapsed_ms=int((time.monotonic() - timer) * 1000)) # fmt: skip
914
- files.append(report)
915
- frames += kept
916
- if report["status"] == "aborted":
917
- break
918
- complete.append(f)
919
- files[skipped:] = sorted(files[skipped:], key=lambda r: r["input_file"] not in plan.prechecked) # fmt: skip
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
- if pending and (
924
- frames or sink.write_mode == "overwrite"
925
- ): # Rust writes nothing when every input was skipped
926
- empty = pl.DataFrame(schema=dict.fromkeys(names, pl.String)).select(
927
- cast_column(n, c.column_type)[0]
928
- for n, c in zip(names, columns, strict=True)
929
- )
930
- frames = frames or [_output(plan, empty)]
931
- if target is None:
932
- write = write_accepted(pl.concat(frames), accepted_client, base, sink.write_mode, sink.accepted.options) # fmt: skip
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
- outputs = [plan.out_map.get(n, n) for n in names]
935
- result, table, merge = write_iceberg(entity, target, frames, outputs, sink.write_mode, emit.run_id, input_hash(entity, complete)) # fmt: skip
936
- write = AcceptedWrite(result.files_written, result.file_paths, result.total_bytes_written,
937
- result.avg_file_size_mb, result.small_files_count) # fmt: skip
938
- if result.already_committed: # #920: the commit conflicted and the guard found this batch in the table
939
- done = {f.source_uri for f in complete}
940
- files[:] = [skipped_report(r["input_file"], "already_committed") if r["input_file"] in done else r for r in files] # fmt: skip
941
- else:
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`. Non-overwrite modes write `part-<uuid>` directly.
62
+ """Yield `put(body) -> name`. Every part is staged as `_staging-<id>-<name>` (invisible to `part_uris`).
63
63
 
64
- Overwrite stages every part as `_staging-<id>-part-NNNNN` (invisible to `part_uris`) and
65
- only when all were written copies them over `part-NNNNN`, deletes the old parts that no new
66
- part replaced, then the staged copies (#1054). A failed write leaves the old parts untouched.
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
- if mode != "overwrite":
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 put_staged(body: bytes) -> str:
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 put_staged
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
- df: pl.DataFrame,
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 `df` as `part-*.parquet` files under `base`, split so each holds about `max_size_per_file`."""
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 [df.slice(i, rows) for i in range(0, df.height, rows)] or [df]:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.3
3
+ Version: 0.8.5
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -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=INYh3NO3JkdgDjTXDi7pylr79nzrJmDuBlrIrm_8LRc,4326
16
+ floe/checks/normalize.py,sha256=4g-Mw-UOtLnitavD3lPWUUYweGqGMUx_8dhq2AJU8oc,4331
17
17
  floe/checks/rows.py,sha256=E-YUZUvBUyOB7zat2dtbPWFM-0T1-01lYKuQU1A6wto,4156
18
- floe/checks/unique.py,sha256=BUu6ezLtQKj8_9_SybGND6rxbUbzlmqh_WJomTHRfGw,9782
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=-cDn9CKfR1BmAATgYy1PWlTWSkpu4mjt4PMKNL7k2FE,35858
27
+ floe/config/validate.py,sha256=7tyofwoTBL-BBCNxnmkXeSOybwmMNngeEcj8zviRyLs,36176
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=QW8L6DKVr4Ug0CbuEP4wgy7Lh5-TsoOXRZWQxfM5zSQ,15115
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=uE0jLhhxRFOeKi7ChAzstKexy1ZrovE4EKATGTYn-Gk,11255
34
- floe/iceberg/unique.py,sha256=PKUFEcCkU71k66__KfUE7NTos73ZYlZbb8GJPn0yFoo,2958
35
- floe/iceberg/write.py,sha256=zrFeeJu-YT4Pxo9u-fRujUtFb1Epx1KFoq2qFyP1iGs,11036
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=ekuB-1JDATvxIFnBTCQpku8uXtrSQNwvryjdTidXk6I,9707
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=4UGhUtjYR84MOe3jjwFmLGWAaDln1z5KOHC0MOvDLpQ,45204
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=mPS3W2bHgeNR_FJ3Ghb13gMgiPpk5-mpSad1bwq97Kg,6034
62
- floe_python-0.8.3.dist-info/METADATA,sha256=DApXCIMAHZD6G1V_b9v5T2APwgUeKUEfWYmUk10EsK4,442
63
- floe_python-0.8.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.3.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.3.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.3.dist-info/RECORD,,
61
+ floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
62
+ floe_python-0.8.5.dist-info/METADATA,sha256=qqbMLRbq0xRacIJKtPlBHp6_4L6jwcPIr2TeYYR-Kwg,442
63
+ floe_python-0.8.5.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
+ floe_python-0.8.5.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
+ floe_python-0.8.5.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
+ floe_python-0.8.5.dist-info/RECORD,,