floe-python 0.8.2__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/_cli.py CHANGED
@@ -40,7 +40,7 @@ def _realpath(path: str) -> Path:
40
40
  def _profile(location: str | None) -> ProfileConfig | None:
41
41
  if not location:
42
42
  return None
43
- return parse_profile(location if location.startswith("s3://") else _realpath(location)) # fmt: skip
43
+ return parse_profile(location if location.startswith(("s3://", "env:")) else _realpath(location)) # fmt: skip
44
44
 
45
45
 
46
46
  def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
@@ -83,7 +83,7 @@ def _validate(args: argparse.Namespace) -> int:
83
83
  print(f" Severity: {e.policy.severity}")
84
84
  profile_arg = ""
85
85
  if args.profile:
86
- profile_arg = f" -p {args.profile if args.profile.startswith('s3://') else os.path.realpath(args.profile)}" # fmt: skip
86
+ profile_arg = f" -p {args.profile if args.profile.startswith(('s3://', 'env:')) else os.path.realpath(args.profile)}" # fmt: skip
87
87
  print(f"Next: floe run -c {path}{profile_arg}")
88
88
  return 0
89
89
 
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)
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/profile.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
2
2
 
3
+ import os
3
4
  from collections.abc import Callable
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
@@ -404,7 +405,20 @@ def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
404
405
  return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
405
406
 
406
407
 
408
+ ENV_PREFIX = "env:"
409
+
410
+
411
+ def read_env_profile(location: str) -> str:
412
+ """`env:NAME`: the profile YAML held in environment variable `NAME`."""
413
+ name = location.removeprefix(ENV_PREFIX)
414
+ if not (text := os.environ.get(name, "")).strip():
415
+ raise FloeConfigError(f"profile environment variable {name} is unset or empty")
416
+ return text
417
+
418
+
407
419
  def _docs(location: str | Path) -> list[object]:
420
+ if isinstance(location, str) and location.startswith(ENV_PREFIX):
421
+ return parse_yaml(read_env_profile(location))
408
422
  if isinstance(location, Path) or not location.startswith("s3://"):
409
423
  return load_yaml(Path(location))
410
424
  try:
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 normalize_name, output_names
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
@@ -2,6 +2,9 @@
2
2
 
3
3
  import os
4
4
  import re
5
+ import shutil
6
+ import tempfile
7
+ import weakref
5
8
  from collections.abc import Generator, Sequence
6
9
  from contextlib import contextmanager
7
10
  from urllib.parse import urlsplit
@@ -16,7 +19,7 @@ from pyiceberg.types import NestedField
16
19
 
17
20
  from floe.config.storage import CatalogDefinition, StorageDefinition
18
21
  from floe.config.types import SchemaEvolutionMode
19
- from floe.errors import FloeConfigError, FloeRunError
22
+ from floe.errors import FloeConfigError, FloeError, FloeRunError
20
23
 
21
24
  # (column, transform) pairs, as in the entity's `sink.accepted.partition_spec`;
22
25
  # a bucket is `bucket[N]` (PyIceberg's notation) with N its `num_buckets` (D12)
@@ -26,6 +29,11 @@ _TRANSFORMS = frozenset({"identity", "year", "month", "day", "hour"})
26
29
  # first id of a partition field, as in the Iceberg spec
27
30
  _PARTITION_FIELD_ID_START = 1000
28
31
  DUCKDB_CATALOG = "floe_catalog"
32
+ # the ADR's limits (python-core-for-olf.md, "Supported limits"); DuckDB's own default is 80% of the
33
+ # host's RAM, not the cgroup limit, so a Kubernetes pod is OOM-killed instead of spilling.
34
+ # Size pods at the memory limit + 2 GiB. The spill bound fails a runaway merge before it fills the disk.
35
+ DUCKDB_MEMORY_LIMIT = "4GB"
36
+ DUCKDB_MAX_TEMP_DIRECTORY_SIZE = "50GB"
29
37
 
30
38
 
31
39
  def transform_name(field: PartitionField) -> str:
@@ -36,6 +44,10 @@ def transform_name(field: PartitionField) -> str:
36
44
  def run_error(context: str) -> Generator[None]:
37
45
  try:
38
46
  yield
47
+ except (
48
+ FloeError
49
+ ): # already worded for the user, e.g. raised by the stream a write reads
50
+ raise
39
51
  except Exception as e: # PyIceberg has no common base exception
40
52
  raise FloeRunError(f"{context}: {e}") from e
41
53
 
@@ -174,8 +186,10 @@ def open_duckdb(
174
186
  raise FloeConfigError(f"iceberg catalog {definition.name}: merges and compaction require a rest catalog (got {definition.type})") # fmt: skip
175
187
  statements = _duckdb_setup(definition, storage)
176
188
  with run_error("duckdb catalog attach failed"):
177
- # ponytail: DuckDB's default memory_limit and spill dir; set the ADR's limits when the run wires merges
178
- con = duckdb.connect()
189
+ spill = tempfile.mkdtemp(prefix="floe-duckdb-")
190
+ con = duckdb.connect(config={"memory_limit": DUCKDB_MEMORY_LIMIT, "temp_directory": spill, "max_temp_directory_size": DUCKDB_MAX_TEMP_DIRECTORY_SIZE}) # fmt: skip
191
+ # the spill directory lives as long as the connection (also on `with` exit and at interpreter exit)
192
+ weakref.finalize(con, shutil.rmtree, spill, True)
179
193
  for statement in statements:
180
194
  con.execute(statement)
181
195
  return con
@@ -298,10 +312,10 @@ def ensure_table(
298
312
  partition_fields: PartitionFields | None = None,
299
313
  schema_evolution: SchemaEvolutionMode = "strict",
300
314
  create_namespace: bool = True,
301
- ) -> Table:
315
+ ) -> tuple[Table, list[str]]:
302
316
  """Create the namespace (unless `create_namespace` is off) and table if missing; otherwise check the table matches (Rust's append path).
303
317
 
304
- 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.
305
319
  """
306
320
  ident = (namespace,)
307
321
  with run_error("rest catalog namespace_exists check failed"):
@@ -338,16 +352,17 @@ def ensure_table(
338
352
  for field in added:
339
353
  update.add_column(field.name, field.field_type, required=False)
340
354
  table.refresh()
341
- return table
355
+ return table, [f.name for f in added]
342
356
  spec = (
343
357
  None
344
358
  if partition_fields is None
345
359
  else build_partition_spec(schema, partition_fields, entity)
346
360
  )
347
361
  with run_error("rest catalog create_table failed"):
348
- 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`
349
363
  identifier,
350
364
  schema,
351
365
  location=location,
352
366
  partition_spec=spec if spec is not None else PartitionSpec(),
353
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/manifest.py CHANGED
@@ -17,7 +17,7 @@ from typing import Any, NamedTuple, cast
17
17
  from pydantic import BaseModel
18
18
 
19
19
  from floe import __version__
20
- from floe.config.profile import ProfileConfig
20
+ from floe.config.profile import ENV_PREFIX, ProfileConfig, read_env_profile
21
21
  from floe.config.project import ReportConfig, RootConfig
22
22
  from floe.config.storage import StoragesConfig
23
23
  from floe.config.types import ArchiveTarget, EntityConfig, SinkTarget, SourceConfig
@@ -464,7 +464,10 @@ def build_manifest(
464
464
  profile_uri = profile_checksum = None
465
465
  if options.profile_arg is not None: # recorded like `config_uri` and its checksum
466
466
  # an s3 profile is read from there by the run, so it is recorded as typed
467
- if options.profile_arg.startswith("s3://"):
467
+ if options.profile_arg.startswith(ENV_PREFIX): # the Job reads the variable too
468
+ profile_uri = options.profile_arg
469
+ profile_checksum = _sha256(read_env_profile(profile_uri).encode())
470
+ elif options.profile_arg.startswith("s3://"):
468
471
  profile_uri = options.profile_arg
469
472
  profile_checksum = _sha256(read_s3(profile_uri, "profile"))
470
473
  else:
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/storage/local.py CHANGED
@@ -101,6 +101,7 @@ class LocalClient:
101
101
  try:
102
102
  dst.parent.mkdir(parents=True, exist_ok=True)
103
103
  mode = src.stat().st_mode & 0o777
104
+ dst.unlink(missing_ok=True) # not through a symlink or shared inode
104
105
  fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
105
106
  os.fchmod(fd, mode) # #779: the open mode applies on create only
106
107
  with src.open("rb") as fin, os.fdopen(fd, "wb") as fout:
floe/write/outputs.py CHANGED
@@ -4,10 +4,9 @@ import io
4
4
  import math
5
5
  import re
6
6
  import uuid
7
- from collections.abc import Generator, Iterator
8
- from contextlib import contextmanager
7
+ from collections.abc import Callable, Generator, Iterable, Iterator
8
+ from contextlib import contextmanager, suppress
9
9
  from dataclasses import dataclass
10
- from itertools import count
11
10
 
12
11
  import polars as pl
13
12
 
@@ -58,30 +57,74 @@ def part_uris(client: StorageClient, base: str, ext: str) -> list[str]:
58
57
  return sorted(u for u in uris if u.rpartition("/")[0] == base and u.endswith(f".{ext}") and _PART.fullmatch(u.rpartition("/")[2].removesuffix(f".{ext}"))) # fmt: skip
59
58
 
60
59
 
61
- def _part_names(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Iterator[str]: # fmt: skip
62
- """Overwrite clears the old parts and numbers from 0 (`part-00000`); other modes use `part-<uuid>`."""
63
- if mode != "overwrite":
64
- while True:
65
- yield f"part-{uuid.uuid4()}.{ext}"
66
- for uri in part_uris(client, base, ext):
60
+ @contextmanager
61
+ def _parts(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Generator[Callable[[bytes], str]]: # fmt: skip
62
+ """Yield `put(body) -> name`. Every part is staged as `_staging-<id>-<name>` (invisible to `part_uris`).
63
+
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.
69
+ """
70
+ overwrite = mode == "overwrite"
71
+ stage = f"_staging-{uuid.uuid4()}-"
72
+ names: list[str] = []
73
+
74
+ def put(body: bytes) -> str:
75
+ name = f"part-{len(names):05}.{ext}" if overwrite else f"part-{uuid.uuid4()}.{ext}" # fmt: skip
76
+ names.append(name) # before the write: a partial file needs cleanup too
77
+ client.write(f"{base}/{stage}{name}", body)
78
+ return name
79
+
80
+ try:
81
+ yield put
82
+ except BaseException:
83
+ for name in names:
84
+ with suppress(FloeError, OSError): # best effort: the original error wins
85
+ client.delete(f"{base}/{stage}{name}")
86
+ raise
87
+ old = part_uris(client, base, ext) if overwrite else []
88
+ for name in names:
89
+ client.copy(f"{base}/{stage}{name}", f"{base}/{name}")
90
+ for uri in set(old) - {f"{base}/{n}" for n in names}:
67
91
  client.delete(uri)
68
- for i in count():
69
- yield f"part-{i:05}.{ext}"
92
+ for name in names:
93
+ client.delete(f"{base}/{stage}{name}")
94
+
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)
70
116
 
71
117
 
72
118
  def write_accepted(
73
- df: pl.DataFrame,
119
+ frames: Iterable[pl.DataFrame],
74
120
  client: StorageClient,
75
121
  base: str,
76
122
  mode: WriteMode = "overwrite",
77
123
  options: SinkOptions | None = None,
78
124
  ) -> AcceptedWrite:
79
- """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`."""
80
126
  base = base.rstrip("/")
81
- names = _part_names(client, base, mode, "parquet")
82
127
  max_size = (options and options.max_size_per_file) or DEFAULT_MAX_SIZE_PER_FILE
83
- avg_row = max(1, math.ceil(df.estimated_size() / df.height)) if df.height else 1
84
- rows = max(1, max_size // avg_row)
85
128
  kwargs: dict[str, str | int] = {}
86
129
  if options and options.compression:
87
130
  kwargs["compression"] = options.compression
@@ -89,14 +132,12 @@ def write_accepted(
89
132
  kwargs["row_group_size"] = options.row_group_size
90
133
  files: list[str] = []
91
134
  sizes: list[int] = []
92
- with _os_errors():
93
- for chunk in [df.slice(i, rows) for i in range(0, df.height, rows)] or [df]:
94
- name = next(names)
135
+ with _os_errors(), _parts(client, base, mode, "parquet") as put:
136
+ for chunk in _part_frames(frames, max_size):
95
137
  # ponytail: a part is held in memory (≤ max_size_per_file) before the put; stream multipart uploads if that is too much
96
138
  body = io.BytesIO()
97
139
  chunk.write_parquet(body, **kwargs) # pyright: ignore[reportArgumentType] # ponytail: kwargs loses the compression Literal; validated by the config
98
- client.write(f"{base}/{name}", body.getvalue())
99
- files.append(name)
140
+ files.append(put(body.getvalue()))
100
141
  sizes.append(body.tell())
101
142
  total = sum(sizes)
102
143
  return AcceptedWrite(
@@ -115,7 +156,10 @@ def write_rejected(df: pl.DataFrame, client: StorageClient, base: str, mode: Wri
115
156
  """
116
157
  with _os_errors():
117
158
  base = base.rstrip("/")
118
- name = f"part-{uuid.uuid5(uuid.NAMESPACE_URL, key)}.csv" if key and mode != "overwrite" else next(_part_names(client, base, mode, "csv")) # fmt: skip
119
- uri = f"{base}/{name}"
120
- client.write(uri, df.write_csv().encode())
121
- return uri
159
+ if key and mode != "overwrite":
160
+ name = f"part-{uuid.uuid5(uuid.NAMESPACE_URL, key)}.csv"
161
+ client.write(f"{base}/{name}", df.write_csv().encode())
162
+ else:
163
+ with _parts(client, base, mode, "csv") as put:
164
+ name = put(df.write_csv().encode())
165
+ return f"{base}/{name}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,38 +1,38 @@
1
1
  floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
2
2
  floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
3
- floe/_cli.py,sha256=6zQRRczAgARgc7PcLrWS7lEzJa6GKsbpf8Zz8GsDjFo,14081
3
+ floe/_cli.py,sha256=hwast3uMX0iUtkIpcChSIVHYkoYi4fR4jwCwQT53lXE,14101
4
4
  floe/_cli_output.py,sha256=VLxWQr-EPtIwklo9xr2CnjMD17U-7JrMUWdTtAP3jrY,8809
5
5
  floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
6
6
  floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
7
7
  floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
8
8
  floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
9
- floe/manifest.py,sha256=GDaa01K9n5vxI0kRQSkM_xjjIwoeF6JN-N1Fktas8Xc,20216
9
+ floe/manifest.py,sha256=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
10
10
  floe/manifest_run.py,sha256=1XLInkpaCofMezMBs_5uIFoR5W7lbwCBFZeqRcnwUAw,12398
11
11
  floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
12
12
  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=8ZfpGFauFLIrq_xHY3iIe-rtuKO88YACDVO0WQJFWCU,4321
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
22
22
  floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
23
- floe/config/profile.py,sha256=mYbyFse2pBUFuV5TMtl0RFrfyPUnJhdklbAX5eSAM88,15564
23
+ 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=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=TeINK-jVZJAurROEeovVNK3s4PY-czeWih1y_KWohP8,14455
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,24 +43,24 @@ 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
53
53
  floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
54
- floe/storage/local.py,sha256=NKjBf_-PkDnbZ3FzXWE7KZ1xY0G_o3tRy0_7nxW7FLs,6307
54
+ floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
55
55
  floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
56
56
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
57
57
  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=5LgN_8PMPPOgkRVZw_KvxYXmc02-meL7iZLeMKVtufQ,4932
62
- floe_python-0.8.2.dist-info/METADATA,sha256=O9qNqOIcnbGRrkwUAcPGKh35ayNq0bG4YwZMpQ-z90g,442
63
- floe_python-0.8.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.2.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.2.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.2.dist-info/RECORD,,
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,,