floe-python 0.8.8__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
floe/checks/unique.py CHANGED
@@ -17,7 +17,6 @@ from floe.checks.rows import RowCheck
17
17
 
18
18
  SAMPLE_LIMIT = 5
19
19
  _SEEN = "__floe_seen"
20
- _DEBUG = "__floe_debug"
21
20
  _COUNT = "__floe_count"
22
21
  _BITS = "_bits" # suffix of the bit-pattern column that keys a float column
23
22
 
@@ -56,45 +55,11 @@ def resolve_unique_keys(
56
55
  return resolved
57
56
 
58
57
 
59
- # `str` Debug; ponytail: non-ASCII non-printable characters (Rust `\\u{..}`) are left raw
60
- _STR_ESCAPES = {
61
- "\\": "\\\\",
62
- '"': '\\"',
63
- "\n": "\\n",
64
- "\r": "\\r",
65
- "\t": "\\t",
66
- "\0": "\\0",
67
- **{chr(i): f"\\u{{{i:x}}}" for i in [*range(1, 32), 127] if chr(i) not in "\n\r\t"},
68
- }
69
-
70
-
71
58
  def _float_bits(s: pl.Series) -> pl.Series:
72
59
  """Rust's `to_bits()`: Polars cannot reinterpret a float, so view the Arrow buffer."""
73
60
  return pl.Series(s.cast(pl.Float64).to_arrow().view(pa.uint64()))
74
61
 
75
62
 
76
- def _debug(name: str, dtype: pl.DataType) -> pl.Expr:
77
- """Rust's `{:?}` of the `UniqueKey` variant: the sample tie-break sorts on these strings."""
78
- c = pl.col(name)
79
- if dtype == pl.String:
80
- escaped = c
81
- for raw, esc in _STR_ESCAPES.items():
82
- escaped = escaped.str.replace_all(raw, esc, literal=True)
83
- return pl.format('String("{}")', escaped)
84
- if dtype == pl.Boolean:
85
- return pl.format("Bool({})", c.cast(pl.String))
86
- if dtype.is_signed_integer():
87
- return pl.format("I64({})", c.cast(pl.String))
88
- if dtype.is_unsigned_integer():
89
- return pl.format("U64({})", c.cast(pl.String))
90
- if dtype.is_float():
91
- return pl.format("F64({})", pl.col(name + _BITS).cast(pl.String))
92
- if isinstance(dtype, pl.Datetime): # chrono prints the fraction only when not zero
93
- return pl.format('Other("{}")', c.dt.to_string("%Y-%m-%d %H:%M:%S%.f"))
94
- # ponytail: date keys use `Other(display)`, unverified against Rust; ties may order differently
95
- return pl.format('Other("{}")', c.cast(pl.String))
96
-
97
-
98
63
  def _text(value: object) -> str:
99
64
  """Rust's `to_string` of a key value."""
100
65
  if isinstance(value, bool):
@@ -221,7 +186,7 @@ class UniqueTracker:
221
186
  return RowCheck("unique", name, message, dup)
222
187
 
223
188
  def results(self) -> list[UniqueResult]:
224
- """Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by Rust's Debug string."""
189
+ """Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by the key values (numbers numerically, strings by code point)."""
225
190
  return [self._result(c) for c in self._constraints]
226
191
 
227
192
  def _result(self, c: _Constraint) -> UniqueResult:
@@ -229,14 +194,9 @@ class UniqueTracker:
229
194
  return UniqueResult(c.columns, 0, [])
230
195
  parts = pl.concat(c.dup_counts)
231
196
  counts = parts.group_by(parts.columns[:-1]).agg(pl.col(_COUNT).sum())
232
- debug = pl.format(
233
- "CompositeKey([{}])",
234
- pl.concat_str([_debug(k, counts.schema[k]) for k in c.ids], separator=", "),
235
- )
236
- top = (
237
- counts.with_columns(debug.alias(_DEBUG))
238
- .sort([_COUNT, _DEBUG], descending=[True, False])
239
- .head(SAMPLE_LIMIT)
197
+ by = [_COUNT, *counts.columns[:-1]]
198
+ top = counts.sort(by, descending=[True] + [False] * (len(by) - 1)).head(
199
+ SAMPLE_LIMIT
240
200
  )
241
201
  samples = [ # at most SAMPLE_LIMIT rows
242
202
  UniqueSample(
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 math
3
4
  import os
4
5
  from collections.abc import Callable, Mapping
5
6
  from dataclasses import dataclass
@@ -45,7 +46,8 @@ CONTRACT = {
45
46
  @dataclass(frozen=True)
46
47
  class ProfileConfig:
47
48
  name: str
48
- runner: dict[str, object] # `type` and every field, None when unset
49
+ # `type` and every field, None when unset; then a kubernetes_job's pass-through keys
50
+ runner: dict[str, object]
49
51
  variables: dict[str, str]
50
52
  catalogs: CatalogsConfig | None
51
53
  storages: StoragesConfig | None
@@ -157,17 +159,6 @@ def _str_keyed(value: object, where: str) -> dict[str, object]:
157
159
  return cast(dict[str, object], hash)
158
160
 
159
161
 
160
- def _object(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
161
- return _str_keyed(hash[key], f"{ctx}.{key}") if key in hash else None
162
-
163
-
164
- def _objects(hash: Hash, key: str, ctx: str) -> list[dict[str, object]] | None:
165
- if key not in hash:
166
- return None
167
- where = f"{ctx}.{key}"
168
- return [_str_keyed(item, f"{where}[]") for item in yaml_array(hash[key], where)]
169
-
170
-
171
162
  def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
172
163
  if key not in hash:
173
164
  return None
@@ -200,24 +191,41 @@ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
200
191
  ("env_parameters", _map),
201
192
  ("job_scope", _job_scope),
202
193
  ("domain_resources", _domain_resources),
203
- ("labels", _map),
204
- ("annotations", _map),
205
- ("image_pull_policy", _string),
206
- ("security_context", _object),
207
- ("node_selector", _map),
208
- ("tolerations", _objects),
209
- ("backoff_limit", _u64),
210
- ("env_from", _objects),
211
194
  )
195
+ # Legacy: the keys any runner type accepted before #1065. A non-k8s runner still accepts and ignores them,
196
+ # so a profile copied from a kubernetes_job keeps loading; they are not validated here.
197
+ _LEGACY_IGNORED = (
198
+ *("labels", "annotations", "image_pull_policy", "security_context"),
199
+ *("node_selector", "tolerations", "backoff_limit", "env_from"),
200
+ )
201
+
202
+
203
+ def _json(value: object, where: str) -> object:
204
+ """A pass-through value as written, once JSON can carry it: string mapping keys, finite numbers."""
205
+ if isinstance(value, dict):
206
+ hash = _str_keyed(cast(object, value), where)
207
+ return {k: _json(v, f"{where}.{k}") for k, v in hash.items()}
208
+ if isinstance(value, list):
209
+ return [_json(v, f"{where}[]") for v in cast(list[object], value)]
210
+ if isinstance(value, float) and not math.isfinite(value):
211
+ raise FloeConfigError(f"{where} must be a finite number")
212
+ return value
212
213
 
213
214
 
214
215
  def _runner(value: object) -> dict[str, object]:
215
216
  ctx = "profile.execution.runner"
216
217
  hash = yaml_hash(value, ctx)
217
- validate_known_keys(hash, ctx, ("type", *(name for name, _ in _RUNNER_FIELDS)))
218
+ known = ("type", *(name for name, _ in _RUNNER_FIELDS))
219
+ # #1065: a kubernetes_job's other keys are pod options its orchestrator validates; they pass through
220
+ open_ = hash.get("type") == "kubernetes_job"
221
+ validate_known_keys(hash, ctx, hash.keys() if open_ else (*known, *_LEGACY_IGNORED))
218
222
  read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
219
223
  for name, reader in _RUNNER_FIELDS:
220
224
  read[name] = reader(hash, name, ctx)
225
+ if open_:
226
+ read |= {
227
+ str(k): _json(v, f"{ctx}.{k}") for k, v in hash.items() if k not in known
228
+ }
221
229
  return read
222
230
 
223
231
 
floe/iceberg/catalog.py CHANGED
@@ -12,7 +12,11 @@ from urllib.parse import urlsplit
12
12
 
13
13
  import duckdb
14
14
  from pyiceberg.catalog import Catalog, load_catalog
15
- from pyiceberg.exceptions import NamespaceAlreadyExistsError
15
+ from pyiceberg.exceptions import (
16
+ NamespaceAlreadyExistsError,
17
+ NoSuchNamespaceError,
18
+ NoSuchTableError,
19
+ )
16
20
  from pyiceberg.partitioning import PartitionField, PartitionSpec
17
21
  from pyiceberg.schema import Schema
18
22
  from pyiceberg.table import Table
@@ -345,55 +349,59 @@ def ensure_table(
345
349
 
346
350
  With `add_columns`, columns new in `schema` are added as optional (D13) and returned by name; partition changes stay rejected.
347
351
  """
348
- ident = (namespace,)
349
- with run_error("rest catalog namespace_exists check failed"):
350
- namespace_exists = catalog.namespace_exists(ident)
351
- if not namespace_exists:
352
- if not create_namespace:
353
- raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
354
- with (
355
- run_error("rest catalog namespace create failed"),
356
- suppress(NamespaceAlreadyExistsError),
357
- ): # another entity or process created it first
358
- catalog.create_namespace(ident)
359
352
  identifier = (namespace, name)
360
- with run_error("rest catalog table_exists check failed"):
361
- exists = catalog.table_exists(identifier)
362
- if exists:
363
- with run_error("rest catalog load_table failed"):
353
+ with run_error("rest catalog load_table failed"):
354
+ try:
364
355
  table = catalog.load_table(identifier)
365
- added: list[NestedField] = []
366
- if schema_evolution == "add_columns":
367
- added = additive_columns(table.schema(), schema, entity)
368
- # field ids of `schema` may differ from the table's once columns are added
369
- check_schema = table.schema()
370
- else:
371
- check_schema_matches(table.schema(), schema, entity)
372
- check_schema = schema
373
- check_partition_spec_matches(
374
- table.spec(),
356
+ except (
357
+ NoSuchTableError,
358
+ NoSuchNamespaceError,
359
+ ): # fmt: skip -- a missing namespace raises either, per catalog
360
+ table = None
361
+ if table is None:
362
+ ident = (namespace,)
363
+ with run_error("rest catalog namespace_exists check failed"):
364
+ namespace_exists = catalog.namespace_exists(ident)
365
+ if not namespace_exists:
366
+ if not create_namespace:
367
+ raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
368
+ with (
369
+ run_error("rest catalog namespace create failed"),
370
+ suppress(NamespaceAlreadyExistsError),
371
+ ): # another entity or process created it first
372
+ catalog.create_namespace(ident)
373
+ spec = (
375
374
  None
376
375
  if partition_fields is None
377
- else build_partition_spec(check_schema, partition_fields, entity),
378
- entity,
376
+ else build_partition_spec(schema, partition_fields, entity)
379
377
  )
380
- if added:
381
- with run_error("rest catalog update_schema failed"):
382
- with table.update_schema() as update:
383
- for field in added:
384
- update.add_column(field.name, field.field_type, required=False)
385
- table.refresh()
386
- return table, [f.name for f in added]
387
- spec = (
378
+ with run_error("rest catalog create_table failed"):
379
+ created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
380
+ identifier,
381
+ schema,
382
+ location=location,
383
+ partition_spec=spec if spec is not None else PartitionSpec(),
384
+ )
385
+ return created, []
386
+ added: list[NestedField] = []
387
+ if schema_evolution == "add_columns":
388
+ added = additive_columns(table.schema(), schema, entity)
389
+ # field ids of `schema` may differ from the table's once columns are added
390
+ check_schema = table.schema()
391
+ else:
392
+ check_schema_matches(table.schema(), schema, entity)
393
+ check_schema = schema
394
+ check_partition_spec_matches(
395
+ table.spec(),
388
396
  None
389
397
  if partition_fields is None
390
- else build_partition_spec(schema, partition_fields, entity)
398
+ else build_partition_spec(check_schema, partition_fields, entity),
399
+ entity,
391
400
  )
392
- with run_error("rest catalog create_table failed"):
393
- created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
394
- identifier,
395
- schema,
396
- location=location,
397
- partition_spec=spec if spec is not None else PartitionSpec(),
398
- )
399
- return created, []
401
+ if added:
402
+ with run_error("rest catalog update_schema failed"):
403
+ with table.update_schema() as update:
404
+ for field in added:
405
+ update.add_column(field.name, field.field_type, required=False)
406
+ table.refresh()
407
+ return table, [f.name for f in added]
floe/iceberg/compact.py CHANGED
@@ -71,7 +71,9 @@ class _RewriteFiles(_OverwriteFiles):
71
71
  for e in entries:
72
72
  writer.delete(e) if e.data_file in self._deleted_data_files else writer.existing(e) # fmt: skip
73
73
  out.append(writer.to_manifest_file())
74
- return out
74
+ return [
75
+ m for m in out if m.has_added_files() or m.has_existing_files()
76
+ ] # #1136: as `_LiveOverwrite`
75
77
 
76
78
  def _summary(self, snapshot_properties: Mapping[str, str] = {}) -> Summary:
77
79
  # built as an OVERWRITE: PyIceberg 0.12's `update_snapshot_summaries` rejects REPLACE
floe/iceberg/sink.py CHANGED
@@ -15,6 +15,7 @@ from typing import Any
15
15
  import polars as pl
16
16
  import pyarrow as pa
17
17
  from pyiceberg.catalog import Catalog
18
+ from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchTableError
18
19
  from pyiceberg.schema import Schema, assign_fresh_schema_ids
19
20
  from pyiceberg.table import Table
20
21
 
@@ -39,6 +40,7 @@ from floe.iceberg.merge import merge_scd1, merge_scd2
39
40
  from floe.iceberg.write import (
40
41
  SMALL_FILE_THRESHOLD_BYTES,
41
42
  IcebergWriteResult,
43
+ parquet_properties,
42
44
  write_table,
43
45
  )
44
46
  from floe.storage.inputs import InputFile
@@ -176,13 +178,15 @@ def write_iceberg(
176
178
  primary_key = entity.schema_.primary_key or [] # validated non-empty for merges
177
179
  key = [output.get(k.strip(), k.strip()) for k in primary_key]
178
180
  identifier = (target.namespace, target.table)
179
- with run_error("rest catalog table_exists check failed"):
180
- # the configured mode: a refreshed merge writes as overwrite and keeps the key guard (#1091)
181
- exists = entity.sink.write_mode in ("merge_scd1", "merge_scd2") and catalog.table_exists(identifier) # fmt: skip
182
- if exists:
181
+ # the configured mode: a refreshed merge writes as overwrite and keeps the key guard (#1091)
182
+ if entity.sink.write_mode in ("merge_scd1", "merge_scd2"):
183
183
  with run_error("rest catalog load_table failed"):
184
- existing = set(catalog.load_table(identifier).schema().column_names)
185
- if added := [k for k in key if k not in existing]: # Rust `merge/shared.rs`
184
+ try:
185
+ existing = set(catalog.load_table(identifier).schema().column_names)
186
+ except (NoSuchTableError, NoSuchNamespaceError):
187
+ existing = None
188
+ # Rust `merge/shared.rs`
189
+ if existing is not None and (added := [k for k in key if k not in existing]):
186
190
  raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
187
191
  table, added = ensure_table(
188
192
  catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
@@ -215,7 +219,7 @@ def write_iceberg(
215
219
  threshold = SMALL_FILE_THRESHOLD_BYTES if size is None else max(1, size // 2)
216
220
  append = "overwrite" if mode == "overwrite" else "append"
217
221
  unique = [[output.get(c, c) for c in k] for k in resolve_unique_keys(entity.schema_.unique_keys, [c.name for c in entity.schema_.columns if c.unique], entity.schema_.primary_key)] # fmt: skip
218
- result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique) # fmt: skip
222
+ result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique, parquet=parquet_properties(options and options.compression, options and options.row_group_size)) # fmt: skip
219
223
  ids = {"table_version": result.metadata_version, "snapshot_id": result.snapshot_id}
220
224
  fields = {k: v for k, v in ids.items() if v is not None} | {
221
225
  "iceberg_catalog_name": target.catalog.name,
floe/iceberg/write.py CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import itertools
7
7
  import uuid
8
- from collections.abc import Callable, Iterator, Sequence
8
+ from collections.abc import Callable, Iterator, Mapping, Sequence
9
9
  from dataclasses import dataclass, replace
10
10
  from datetime import datetime
11
11
  from pathlib import PurePosixPath
@@ -18,9 +18,17 @@ from pyiceberg.io.pyarrow import (
18
18
  _check_pyarrow_schema_compatible, # pyright: ignore[reportPrivateUsage] -- same check `Transaction.append` makes
19
19
  _dataframe_to_data_files, # pyright: ignore[reportPrivateUsage] -- feeds one snapshot producer from many chunks
20
20
  )
21
- from pyiceberg.manifest import DataFile, DataFileContent, ManifestEntryStatus
21
+ from pyiceberg.manifest import (
22
+ DataFile,
23
+ DataFileContent,
24
+ ManifestEntryStatus,
25
+ ManifestFile,
26
+ )
22
27
  from pyiceberg.table import Table, TableProperties
23
- from pyiceberg.table.snapshots import ancestors_of
28
+ from pyiceberg.table.snapshots import Operation, ancestors_of
29
+ from pyiceberg.table.update.snapshot import (
30
+ _OverwriteFiles, # pyright: ignore[reportPrivateUsage] -- PyIceberg 0.12 has no public hook on the manifest list
31
+ )
24
32
 
25
33
  from floe.errors import FloeRunError
26
34
  from floe.iceberg.catalog import run_error
@@ -55,6 +63,14 @@ class IcebergWriteResult:
55
63
  )
56
64
 
57
65
 
66
+ class _LiveOverwrite(_OverwriteFiles):
67
+ """PyIceberg 0.12 keeps the all-DELETED manifest of every earlier overwrite in the manifest list, one more per
68
+ overwrite, so the scan and the commit read more manifests each time (#1132); Java drops them, so do we."""
69
+
70
+ def _existing_manifests(self) -> list[ManifestFile]:
71
+ return [m for m in super()._existing_manifests() if m.has_added_files() or m.has_existing_files()] # fmt: skip
72
+
73
+
58
74
  def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]:
59
75
  pending: list[pa.RecordBatch] = []
60
76
  rows = 0
@@ -72,6 +88,16 @@ def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]
72
88
  yield pa.Table.from_batches(pending, reader.schema)
73
89
 
74
90
 
91
+ def parquet_properties(
92
+ compression: str | None, row_group_size: int | None
93
+ ) -> dict[str, str]:
94
+ """The sink's `compression` and `row_group_size` as PyIceberg's write properties; unset keeps the table's."""
95
+ props = {TableProperties.PARQUET_COMPRESSION: compression} if compression else {}
96
+ if row_group_size:
97
+ props[TableProperties.PARQUET_ROW_GROUP_LIMIT] = str(row_group_size)
98
+ return props
99
+
100
+
75
101
  def _metadata_version(location: str | None) -> int | None:
76
102
  prefix = PurePosixPath(location or "").name.split("-", 1)[0]
77
103
  return int(prefix) if prefix.isdigit() else None
@@ -86,22 +112,25 @@ def write_table(
86
112
  small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
87
113
  chunk_rows: int = CHUNK_ROWS,
88
114
  unique_keys: Sequence[Sequence[str]] = (),
115
+ parquet: Mapping[str, str] | None = None,
89
116
  ) -> IcebergWriteResult:
90
117
  """Data files are written chunk by chunk (partitioned tables split each chunk), then committed once.
91
118
 
92
119
  Overwrite replaces every existing file in the same snapshot. An append of no rows commits nothing.
93
120
  On a conflict an append checks `unique_keys` (table column names) against the files added since the base
94
121
  snapshot and fails closed, committing nothing, when a rival already holds a key (#549).
122
+ `parquet` holds `write.parquet.*` properties for these data files only; the table's own properties are not changed.
95
123
  """
96
124
  with run_error("iceberg data write failed"):
97
125
  _check_pyarrow_schema_compatible(table.schema(), reader.schema)
126
+ metadata = table.metadata.model_copy(update={"properties": {**table.properties, **(parquet or {})}}) # fmt: skip
98
127
  counter = itertools.count()
99
128
  write_uuid = uuid.uuid4()
100
129
  data_files: list[DataFile] = [
101
130
  data_file
102
131
  for chunk in _chunks(reader, chunk_rows)
103
132
  for data_file in _dataframe_to_data_files(
104
- table.metadata, chunk, table.io, write_uuid, counter
133
+ metadata, chunk, table.io, write_uuid, counter
105
134
  )
106
135
  ]
107
136
  digest = (
@@ -131,13 +160,21 @@ def write_table(
131
160
  # PyIceberg retries a 409 itself (refresh, rebuild) and would skip the guard; in memory only, never sent
132
161
  table.metadata = table.metadata.model_copy(update={"properties": {**table.properties, TableProperties.COMMIT_NUM_RETRIES: "0"}}) # fmt: skip
133
162
  tx = table.transaction()
134
- update = tx.update_snapshot(
135
- snapshot_properties={RUN_ID: run_id, INPUT_HASH: digest}
136
- )
163
+ props = {RUN_ID: run_id, INPUT_HASH: digest}
137
164
  with (
138
- update.overwrite()
165
+ _LiveOverwrite(
166
+ operation=Operation.OVERWRITE
167
+ if table.current_snapshot()
168
+ else Operation.APPEND,
169
+ transaction=tx,
170
+ io=table.io,
171
+ branch="main",
172
+ snapshot_properties=props,
173
+ )
139
174
  if mode == "overwrite"
140
- else update.fast_append() as snap
175
+ else tx.update_snapshot(
176
+ snapshot_properties=props
177
+ ).fast_append() as snap
141
178
  ):
142
179
  for old in replaced:
143
180
  snap.delete_data_file(old)
floe/manifest.py CHANGED
@@ -275,10 +275,6 @@ _RUNNER_FIELDS = (
275
275
  *("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
276
276
  *("python_file_uri", "job_name", "auth", "env_parameters"),
277
277
  )
278
- _PYTHON_RUNNER_FIELDS = ( # carried only when set
279
- *("job_scope", "domain_resources", "labels", "annotations", "image_pull_policy"),
280
- *("security_context", "node_selector", "tolerations", "backoff_limit", "env_from"),
281
- )
282
278
 
283
279
 
284
280
  # Rust `runners_contract`: the fields each profile runner type carries into the manifest
@@ -303,9 +299,10 @@ def _runners(profile: ProfileConfig | None) -> Json:
303
299
  v = dict(sorted(cast(dict[str, str], v).items()))
304
300
  runner[k] = v
305
301
  if kind == "kubernetes_job":
306
- for k in _PYTHON_RUNNER_FIELDS:
307
- if (v := profile.runner.get(k)) is not None:
308
- runner[k] = v
302
+ # `job_scope`, `domain_resources` and the pod options (#1065), only when set
303
+ runner |= {
304
+ k: v for k, v in profile.runner.items() if k not in runner and v is not None
305
+ }
309
306
  if kind == "databricks_job":
310
307
  runner["job_name"] = runner["job_name"] or "floe-{domain}-{env}"
311
308
  return {"default": "default", "definitions": {"default": runner}}
floe/run/__init__.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import time
4
4
  from collections.abc import Sequence
5
+ from concurrent.futures import ThreadPoolExecutor
5
6
  from dataclasses import dataclass
6
7
  from datetime import UTC, datetime
7
8
  from pathlib import Path
@@ -158,12 +159,15 @@ def run(
158
159
 
159
160
  emit("run_started", config=config, report_base=report_path)
160
161
  # Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
161
- listed: dict[str, ResolvedInputs] = {}
162
- for e in selected:
163
- # D24: a full refresh may rebuild from the archive alone, so run_entity decides
164
- archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
165
- if not archive:
166
- listed[e.id] = inputs(e)
162
+ # D24: a full refresh may rebuild from the archive alone, so run_entity decides
163
+ to_list = [e for e in selected if not (full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive"))] # fmt: skip
164
+ # `map` yields in config order, so the first failure in config order is raised
165
+ if workers > 1 and len(to_list) > 1:
166
+ with ThreadPoolExecutor(workers, thread_name_prefix="floe-list") as pool:
167
+ found = list(pool.map(inputs, to_list))
168
+ else:
169
+ found = [inputs(e) for e in to_list]
170
+ listed = {e.id: f for e, f in zip(to_list, found, strict=True)}
167
171
  client = storage_client(report_storage)
168
172
 
169
173
  def finish(e: EntityConfig) -> dict[str, Any]:
floe/run/entity.py CHANGED
@@ -7,7 +7,7 @@ import time
7
7
  from collections.abc import Callable, Generator, Iterable, Iterator
8
8
  from contextlib import closing, suppress
9
9
  from dataclasses import dataclass, field, replace
10
- from datetime import datetime
10
+ from datetime import date, datetime
11
11
  from functools import partial
12
12
  from itertools import chain
13
13
  from pathlib import Path
@@ -93,6 +93,7 @@ from floe.storage.partitioning import (
93
93
  advance,
94
94
  in_load_order,
95
95
  listing_start,
96
+ partition_date,
96
97
  partition_of,
97
98
  source_root,
98
99
  window,
@@ -313,7 +314,11 @@ def _with_archived(e: EntityConfig, archive: tuple[str, StorageClient], source:
313
314
  source()
314
315
  ) # nothing archived: the source's "no input files matched" stands
315
316
  if p is not None: # D29: the archive keeps the partition sub-paths
316
- old = in_load_order(p, e.name, f"{base}/{e.name}", old, archived=True)
317
+ # #939: D28 orders a day's batches by the source's modification time, which the copy's own does not keep
318
+ kept = [replace(f, source_mtime=client.source_mtime(f.source_uri) or f.source_mtime) for f in old.files] # fmt: skip
319
+ old = in_load_order(
320
+ p, e.name, f"{base}/{e.name}", replace(old, files=kept), archived=True
321
+ )
317
322
  try:
318
323
  new = source()
319
324
  except NoInputsError: # no new source file: rebuild from the archive alone
@@ -402,7 +407,8 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
402
407
  def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
403
408
  # Rust skips the raw copy under PII: the file cannot be masked
404
409
  plan.tracker.restore(keys) # the unreadable file leaves no keys behind
405
- keep = entity.pii is None and entity.sink.rejected is not None
410
+ # a file removed after listing has nothing to copy: the read error alone is reported
411
+ keep = entity.pii is None and entity.sink.rejected is not None and path.exists()
406
412
  raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
407
413
  return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
408
414
 
@@ -633,6 +639,7 @@ class Incremental:
633
639
  # the file reports, set once the sink step is done (#915)
634
640
  self.committed: list[dict[str, Any]] | None = None
635
641
  self.advance: Callable[[EntityState, list[str]], None] | None = None # D30
642
+ self.dated: Callable[[str], date | None] | None = None # #949: an input's partition date # fmt: skip
636
643
  self._stop = threading.Event()
637
644
  self._thread: threading.Thread | None = None
638
645
 
@@ -643,9 +650,11 @@ class Incremental:
643
650
  return inputs, []
644
651
  self.uri, self.client = state_uri(e, self.config_dir, self.storages)
645
652
  if self.full_refresh:
646
- self._hold(claim_all(self.client, self.uri, e.name, self.run_id, inputs))
653
+ # #949: `expired` holds the claims it replaced, which `release` restores if the refresh wrote nothing
654
+ claimed, self.expired = claim_all(self.client, self.uri, e.name, self.run_id, inputs) # fmt: skip
655
+ self._hold(claimed)
647
656
  return inputs, []
648
- got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge")) # fmt: skip
657
+ got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge"), self.dated) # fmt: skip
649
658
  reports = skipped_reports(e, got.already_processed, got.active_claims)
650
659
  _emit_skipped(emit, e.name, reports)
651
660
  self.expired = got.expired
@@ -715,10 +724,17 @@ class Incremental:
715
724
  return claimed
716
725
 
717
726
  def release(self) -> None:
718
- """The dead runs' claims this run took over go back: the next run checks them again (#545)."""
727
+ """The dead runs' claims this run took over go back: the next run checks them again (#545).
728
+
729
+ #949: a full refresh puts back the claims it replaced only if it wrote nothing, as its overwrite drops their rows
730
+ and the retry guard would find their run's snapshot behind it.
731
+ """
719
732
  if claimed := self._end():
733
+ restore = (
734
+ {} if self.full_refresh and self.committed is not None else self.expired
735
+ )
720
736
  # D30: an older day's released file stays listed for a retry
721
- release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
737
+ release(self.client, self.uri, self.run_id, claimed, restore, self.advance is not None and not self.full_refresh) # fmt: skip
722
738
 
723
739
  def finish(self, files: list[dict[str, Any]]) -> None:
724
740
  """Promote unless the entity failed (Rust commits on success, warnings and rejected).
@@ -786,12 +802,18 @@ def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig
786
802
  after = listing_start(p, start) if p and start else None
787
803
 
788
804
  def source() -> ResolvedInputs:
789
- inputs = resolve_inputs(
790
- config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
791
- src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
792
- recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
793
- after=f"{root}/{after}" if after else "",
794
- ) # fmt: skip
805
+ try:
806
+ inputs = resolve_inputs(
807
+ config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
808
+ src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
809
+ recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
810
+ after=f"{root}/{after}" if after else "",
811
+ ) # fmt: skip
812
+ except NoInputsError:
813
+ # #1139: an empty incremental source has nothing pending; a full refresh still fails (a broken extraction)
814
+ if entity.incremental_mode != "file" or full_refresh:
815
+ raise
816
+ return ResolvedInputs([], [], "directory")
795
817
  if not p:
796
818
  return inputs
797
819
  inputs = in_load_order(p, entity.name, root, inputs, start=start)
@@ -921,6 +943,7 @@ def _run_entity(
921
943
  if entity.source.partitioning and entity.incremental_mode == "file":
922
944
  dated = entity.source.partitioning, entity.name, source_root(source_uri)
923
945
  inc.advance = partial(advance, *dated)
946
+ inc.dated = partial(partition_date, *dated)
924
947
  archive = plan.archive # a full refresh lists it too
925
948
  plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
926
949
  # D34 (#931): same store only (a bucket name repeats across endpoints); before the claim, so a refusal leaves nothing to release
floe/state.py CHANGED
@@ -6,7 +6,7 @@ import random
6
6
  import time
7
7
  from collections.abc import Callable
8
8
  from dataclasses import asdict, dataclass
9
- from datetime import UTC, datetime, timedelta
9
+ from datetime import UTC, date, datetime, timedelta
10
10
  from typing import NamedTuple
11
11
 
12
12
  from floe.errors import FloeConfigError
@@ -44,6 +44,8 @@ class EntityState:
44
44
  files: dict[str, FileState]
45
45
  claims: dict[str, Claim]
46
46
  watermark: str | None = None # D30: the latest partition date ingested (ISO)
47
+ # #949: `files` holds no entry dated before it (ISO); only a full refresh lists before it
48
+ pruned_before: str | None = None
47
49
 
48
50
 
49
51
  class Loaded(NamedTuple):
@@ -74,6 +76,8 @@ def dump_state(state: EntityState) -> bytes:
74
76
  }
75
77
  if state.watermark: # D30: additive, absent without partitioning
76
78
  doc["watermark"] = state.watermark
79
+ if state.pruned_before: # #949: additive too
80
+ doc["pruned_before"] = state.pruned_before
77
81
  return json.dumps(doc, indent=2, ensure_ascii=False).encode()
78
82
 
79
83
 
@@ -89,6 +93,7 @@ def parse_state(entity: str, body: bytes) -> EntityState:
89
93
  if schema != SCHEMA_V1
90
94
  else {},
91
95
  doc.get("watermark"),
96
+ doc.get("pruned_before"),
92
97
  )
93
98
  except (ValueError, KeyError, TypeError, AttributeError) as e:
94
99
  raise FloeConfigError(f"entity.name={entity} invalid state file: {e}") from e
@@ -96,6 +101,13 @@ def parse_state(entity: str, body: bytes) -> EntityState:
96
101
  raise FloeConfigError(
97
102
  f"entity.name={entity} state schema mismatch: expected {SCHEMA_V1} or {SCHEMA_V2}, got {schema}"
98
103
  )
104
+ if state.pruned_before is not None: # #949: compared as an ISO date string
105
+ try:
106
+ date.fromisoformat(state.pruned_before)
107
+ except (ValueError, TypeError) as e:
108
+ raise FloeConfigError(
109
+ f"entity.name={entity} invalid state file: pruned_before: {e}"
110
+ ) from e
99
111
  if state.entity != entity:
100
112
  raise FloeConfigError(
101
113
  f"entity.name={entity} state entity mismatch: expected {entity}, got {state.entity}"
@@ -138,8 +150,13 @@ def claim_inputs(
138
150
  run_id: str,
139
151
  inputs: list[InputFile],
140
152
  barrier: bool = False,
153
+ dated: Callable[[str], date | None] | None = None,
141
154
  ) -> ClaimOutcome:
142
- """`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`."""
155
+ """`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`.
156
+
157
+ `dated` (#949): an input's partition date; an unclaimed one dated before `pruned_before` is dropped, as a later
158
+ window would not list it (a run that listed before another pruned would re-ingest it).
159
+ """
143
160
  if not inputs:
144
161
  return ClaimOutcome([], None, [], [], {})
145
162
  for attempt in range(CAS_RETRIES):
@@ -157,6 +174,10 @@ def claim_inputs(
157
174
  processed: list[tuple[InputFile, FileState]] = []
158
175
  acquired_at, expires_at = _now(), _now(CLAIM_TTL_SECONDS)
159
176
  for f in inputs:
177
+ day = dated(f.source_uri) if dated and state.pruned_before else None
178
+ # a claimed one is a retry: the window is held back to it
179
+ if day and day.isoformat() < (state.pruned_before or "") and f.source_uri not in expired and f.source_uri not in state.claims: # fmt: skip
180
+ continue
160
181
  if f.source_uri in state.files:
161
182
  processed.append((f, state.files[f.source_uri]))
162
183
  elif blocked or f.source_uri in state.claims:
@@ -186,8 +207,11 @@ def claim_inputs(
186
207
 
187
208
  def claim_all(
188
209
  client: StorageClient, uri: str, entity: str, run_id: str, inputs: list[InputFile]
189
- ) -> Loaded:
190
- """Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`)."""
210
+ ) -> tuple[Loaded, dict[str, Claim]]:
211
+ """Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`).
212
+
213
+ Also the claims it replaced, by URI, for `release` to restore if the refresh fails (#949).
214
+ """
191
215
  for attempt in range(CAS_RETRIES):
192
216
  if attempt:
193
217
  _backoff(attempt - 1)
@@ -197,11 +221,13 @@ def claim_all(
197
221
  f.source_uri: Claim(run_id, acquired_at, expires_at, f.source_size, f.source_mtime)
198
222
  for f in inputs
199
223
  } # fmt: skip
200
- # D30: no watermark until the refresh promotes, so a failed one leaves the next run listing everything
201
- fresh = Loaded(EntityState(entity, acquired_at, loaded.state.files, claims), loaded.version) # fmt: skip
224
+ # D30: no watermark until the refresh promotes; #949: `pruned_before` stays, so after a failed one the next run
225
+ # lists from it, never the pruned days
226
+ fresh = Loaded(EntityState(entity, acquired_at, loaded.state.files, claims, None, loaded.state.pruned_before), loaded.version) # fmt: skip
202
227
  version = _persist(client, uri, fresh)
203
228
  if version is not None:
204
- return Loaded(fresh.state, version)
229
+ replaced = {u: c for u, c in loaded.state.claims.items() if u in claims}
230
+ return Loaded(fresh.state, version), replaced
205
231
  raise FloeConfigError(
206
232
  f"entity.name={entity} full-refresh state write conflicted after {CAS_RETRIES} retries"
207
233
  )
@@ -225,7 +251,7 @@ def _mutate(
225
251
  )
226
252
  mutate(loaded.state, ours)
227
253
  state = loaded.state
228
- if state.files or state.claims or state.watermark:
254
+ if state.files or state.claims or state.watermark or state.pruned_before:
229
255
  done = _persist(client, uri, loaded) is not None
230
256
  else: # Rust deletes an emptied state
231
257
  done = client.delete_conditional(uri, loaded.version)
floe/storage/archive.py CHANGED
@@ -21,7 +21,7 @@ def archive_input_file(
21
21
  f"{entity_name}/{subdir}" if subdir else entity_name, input_file.source_name, run_id, input_file.source_uri
22
22
  ) # fmt: skip
23
23
  dest_uri = join_relative(relative)
24
- client.copy(input_file.source_uri, dest_uri)
24
+ client.copy(input_file.source_uri, dest_uri, input_file.source_mtime)
25
25
  try:
26
26
  client.delete(input_file.source_uri)
27
27
  except FloeError as e:
floe/storage/local.py CHANGED
@@ -96,7 +96,8 @@ class LocalClient:
96
96
  key=lambda r: r.uri,
97
97
  )
98
98
 
99
- def copy(self, src_uri: str, dst_uri: str) -> None:
99
+ def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
100
+ """`source_mtime` is not kept: a local copy's nanosecond mtime already follows the archive (load) order (#939)."""
100
101
  src, dst = _path(src_uri), _path(dst_uri)
101
102
  try:
102
103
  dst.parent.mkdir(parents=True, exist_ok=True)
@@ -109,6 +110,9 @@ class LocalClient:
109
110
  except OSError as e:
110
111
  raise FloeStorageError(f"local copy failed from {src} to {dst}: {e}") from e
111
112
 
113
+ def source_mtime(self, uri: str) -> str | None:
114
+ return None
115
+
112
116
  def write(self, uri: str, body: bytes) -> None:
113
117
  path = _path(uri)
114
118
  try:
@@ -76,7 +76,7 @@ def _day(when: date | datetime) -> date:
76
76
  return when.date() if isinstance(when, datetime) else when
77
77
 
78
78
 
79
- def _dated(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None:
79
+ def partition_date(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None: # fmt: skip
80
80
  """The partition date of a state entry; None for one outside the pattern (another source path)."""
81
81
  try:
82
82
  return _day(partition_of(p, entity, root, uri).when)
@@ -89,10 +89,12 @@ def window(p: SourcePartitioning, entity: str, root: str, state: EntityState | N
89
89
 
90
90
  An outstanding claim holds it back to the claim's date, so a crashed run's files stay listed until it is retried.
91
91
  """
92
- if state is None or state.watermark is None:
92
+ if state is None or not (state.watermark or state.pruned_before):
93
93
  return None
94
- start = date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)
95
- return min([start, *(d for u in state.claims if (d := _dated(p, entity, root, u)))]) # fmt: skip
94
+ # #949: never before `pruned_before`, whatever `lookback_days` became
95
+ floor = date.fromisoformat(state.pruned_before or date.min.isoformat())
96
+ start = max(floor, date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)) if state.watermark else floor # fmt: skip
97
+ return min([start, *(d for u in state.claims if (d := partition_date(p, entity, root, u)))]) # fmt: skip
96
98
 
97
99
 
98
100
  def listing_start(p: SourcePartitioning, start: date) -> str | None:
@@ -107,10 +109,18 @@ def listing_start(p: SourcePartitioning, start: date) -> str | None:
107
109
 
108
110
 
109
111
  def advance(p: SourcePartitioning, entity: str, root: str, state: EntityState, promoted: list[str]) -> None: # fmt: skip
110
- """D30, on promote: the watermark moves to the latest date promoted, never back (#949 will prune `files`).
112
+ """D30, on promote: the watermark moves to the latest date promoted, never back.
111
113
 
112
114
  Without one (a first run, or after a failed full refresh), every file in the state counts.
115
+ #949: `pruned_before` moves to the window start, never back nor past a claim's date, and `files` drops the entries
116
+ dated before it, in the same state write.
113
117
  """
114
118
  known = promoted if state.watermark else [*promoted, *state.files]
115
- days = [d.isoformat() for u in known if (d := _dated(p, entity, root, u))]
119
+ days = [d.isoformat() for u in known if (d := partition_date(p, entity, root, u))]
116
120
  state.watermark = max([state.watermark or "", *days]) or None
121
+ if state.watermark is None:
122
+ return
123
+ start = date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)
124
+ held = [d for u in state.claims if (d := partition_date(p, entity, root, u))]
125
+ state.pruned_before = max(state.pruned_before or "", min([start, *held]).isoformat()) # fmt: skip
126
+ state.files = {u: f for u, f in state.files.items() if (d := partition_date(p, entity, root, u)) is None or d.isoformat() >= state.pruned_before} # fmt: skip
floe/storage/s3.py CHANGED
@@ -4,6 +4,7 @@ import os
4
4
  import shutil
5
5
  from datetime import UTC, datetime
6
6
  from pathlib import Path
7
+ from typing import Any
7
8
 
8
9
  import boto3
9
10
  from botocore.config import Config
@@ -15,6 +16,17 @@ from floe.storage.types import ObjectRef, StoredObject
15
16
 
16
17
  _CONFLICT = ("PreconditionFailed", "ConditionalRequestConflict", "412", "409")
17
18
  _NOT_FOUND = ("NoSuchKey", "NotFound", "404")
19
+ # #939: an archive copy's `LastModified` is its copy time, to the second; this keeps the source's, for D28 replay order
20
+ _SOURCE_MTIME = "floe-source-mtime"
21
+ # the headers a `MetadataDirective=REPLACE` copy would otherwise drop
22
+ _KEPT = (
23
+ "ContentType",
24
+ "ContentEncoding",
25
+ "ContentDisposition",
26
+ "ContentLanguage",
27
+ "CacheControl",
28
+ "Expires",
29
+ )
18
30
 
19
31
 
20
32
  def resolve_path_style_access(configured: bool | None) -> bool:
@@ -117,15 +129,33 @@ class S3Client:
117
129
  except (ClientError, BotoCoreError) as e:
118
130
  raise FloeStorageError(f"s3 put object failed: {e}") from e
119
131
 
120
- def copy(self, src_uri: str, dst_uri: str) -> None:
132
+ def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
133
+ """`source_mtime` goes to the copy's `x-amz-meta-floe-source-mtime`, with the source's metadata and headers."""
121
134
  (sb, sk), (db, dk) = parse_s3_uri(src_uri), parse_s3_uri(dst_uri)
122
135
  try:
136
+ extra: dict[str, Any] = {}
137
+ if source_mtime:
138
+ head = self._client.head_object(Bucket=sb, Key=sk)
139
+ meta = {**head.get("Metadata", {}), _SOURCE_MTIME: source_mtime}
140
+ extra = {k: head[k] for k in _KEPT if k in head} | {"Metadata": meta, "MetadataDirective": "REPLACE"} # fmt: skip
123
141
  self._client.copy_object(
124
- Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}
142
+ Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}, **extra
125
143
  )
126
144
  except (ClientError, BotoCoreError) as e:
127
145
  raise FloeStorageError(f"s3 copy object failed: {e}") from e
128
146
 
147
+ def source_mtime(self, uri: str) -> str | None:
148
+ """#939: the source modification time an archive copy keeps; None on a copy made before it was kept."""
149
+ bucket, key = parse_s3_uri(uri)
150
+ try:
151
+ meta = self._client.head_object(Bucket=bucket, Key=key).get("Metadata", {})
152
+ # a Go server (MinIO, SeaweedFS) may send the header canonicalised: `X-Amz-Meta-Floe-Source-Mtime`
153
+ return next(
154
+ (v for k, v in meta.items() if k.lower() == _SOURCE_MTIME), None
155
+ )
156
+ except (ClientError, BotoCoreError) as e:
157
+ raise FloeStorageError(f"s3 head object failed for {uri}: {e}") from e
158
+
129
159
  def delete(self, uri: str) -> None:
130
160
  bucket, key = parse_s3_uri(uri)
131
161
  try:
floe/storage/types.py CHANGED
@@ -20,7 +20,10 @@ class StorageClient(Protocol):
20
20
  def resolve_uri(self, path: str) -> str: ...
21
21
  def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]: ...
22
22
  def write(self, uri: str, body: bytes) -> None: ...
23
- def copy(self, src_uri: str, dst_uri: str) -> None: ...
23
+ def copy(
24
+ self, src_uri: str, dst_uri: str, source_mtime: str | None = None
25
+ ) -> None: ...
26
+ def source_mtime(self, uri: str) -> str | None: ...
24
27
  def delete(self, uri: str) -> None: ...
25
28
  def read(self, uri: str) -> StoredObject | None: ...
26
29
  def write_conditional(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.8
3
+ Version: 0.9.0
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -6,33 +6,33 @@ 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=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
9
+ floe/manifest.py,sha256=TDjSOrDXoH59lcYQ_FGIGCEg2JZ0EKGMw4cVtd1kzCA,20312
10
10
  floe/manifest_run.py,sha256=8rkHX2RuxHyp02NI2NLBsU6xq6m5yebR0eoYETV_7Tw,12591
11
11
  floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
12
12
  floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
13
+ floe/state.py,sha256=H7BRVmpw2zfwPR9FiLqbHHV53hoxHNvXGTbT9jHonRU,12635
14
14
  floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
15
15
  floe/checks/mismatch.py,sha256=RNqRL1nihH6Y83RI-dxPXf0WTWZccXq6mnR4m6V0IIo,5827
16
16
  floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
17
17
  floe/checks/rows.py,sha256=-yPieSus0-7ZewLv2vR8waHqQ9DGMbVvmJh1_hK4eDs,4433
18
- floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
18
+ floe/checks/unique.py,sha256=ZVckMOoGuKIzq3M84TGeZ1ks0nab9YTaI54w5Q_rjZc,10864
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=4imKVdZ-Ehbj71OA3K4PrShE-gOYuicMDnTD-QHIkA0,16552
23
+ floe/config/profile.py,sha256=d_Lw8SGdO1h4of7Cl0fraAfB3W5sMYZvECguxgsdIGA,17253
24
24
  floe/config/project.py,sha256=hUsnjbNAEiiy_iVJYA1IDquZ4OY4OF7JpNaEVJ_ocCg,14221
25
25
  floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
26
26
  floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
27
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=R0IBTaLe80k49SUuVMoAw0j7Ira2EDgjjk3XHSISIGg,16573
31
- floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
30
+ floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
31
+ floe/iceberg/compact.py,sha256=te8QLzg-iU_Y7Z9z0BSqMf_vjuV6CzQGV_3OeutSaZg,7237
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
- floe/iceberg/sink.py,sha256=WyT6yrp5JQyfaqWES96NWh3V-EzgTD4Y-IjB84mZ9Sg,11842
33
+ floe/iceberg/sink.py,sha256=vdkgLjqvWH7opOrfc-147xCMQhLnxRjUzLjHLchHlAM,12043
34
34
  floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
35
- floe/iceberg/write.py,sha256=SRX5tb17zUeWwpg6uXKkVQ_muNJ2GqlQd0n-kmjWPVc,11141
35
+ floe/iceberg/write.py,sha256=YNE8IwLV9jK3vksO0XCj36HKHqW6ZEHCu4173Xpi6hg,12854
36
36
  floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
37
37
  floe/lineage/observer.py,sha256=IFHjzsT65L-NkcQ8Noa_8CDVpLu4iGadRB2-K0Lj8t8,10216
38
38
  floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -45,24 +45,24 @@ floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
45
45
  floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
46
46
  floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
47
47
  floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
48
- floe/run/__init__.py,sha256=leKCs7YOoPADLFzKzU8m-mdfmStbGz_VH9kAg12kzyo,8902
49
- floe/run/entity.py,sha256=e-IfES1ajZtIBrX9UnWDOpFHSoxjfYRtCBp63QavCFY,49653
48
+ floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
49
+ floe/run/entity.py,sha256=MlNTdUM8dcZxubB_0FkjfSGbSz2TMcaBBm9Q9V4P7Rk,51072
50
50
  floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
51
51
  floe/run/parallel.py,sha256=lKBW9NOpOu6KFOcEo-RQ56ukEOKHXPPoLI3o6gmlY_g,3435
52
52
  floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
- floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
53
+ floe/storage/archive.py,sha256=JQKizd5PcWWhOFr4nxnkp2OT8Tl1h-Bu28EXWWj-gMc,1126
54
54
  floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
55
- floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
56
- floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
55
+ floe/storage/local.py,sha256=uz7LrtjItFKVtnzna3b06hv5Cv3ov1l79Y2NmxUJ-pg,6617
56
+ floe/storage/partitioning.py,sha256=kheJLDtRyiizgsCiv_8DChAv9Rc5dOxF0q8_yVZEjFE,7057
57
57
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
58
58
  floe/storage/prefetch.py,sha256=b-_be13xkJIw3LVndOb6SouTd1Pf8uZwG6Wy0Nk1pr8,1801
59
59
  floe/storage/resolve.py,sha256=0hGSdU_p10j9vzET25Kcnd-QF6H7uN-hzpo_SC-DV_c,3154
60
- floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
61
- floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
60
+ floe/storage/s3.py,sha256=jG9GWCSSl1NtFus8C_vCe1wtHuKKZs1Jwz0i3jFFT1M,9074
61
+ floe/storage/types.py,sha256=sb-XQr0f7f4JgmfuAFRjHaaGmn63T7v09AvvScXGOTg,1096
62
62
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
63
  floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
64
- floe_python-0.8.8.dist-info/METADATA,sha256=nkx-wlv0W9muP3wviq1E6TBG-fS7wPa1Szy7qjGig_E,442
65
- floe_python-0.8.8.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
- floe_python-0.8.8.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
- floe_python-0.8.8.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
- floe_python-0.8.8.dist-info/RECORD,,
64
+ floe_python-0.9.0.dist-info/METADATA,sha256=XrdM3vzJi9FKxGWCeybRfWpBKMYvvnZAMt-zD2AZTKA,442
65
+ floe_python-0.9.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
+ floe_python-0.9.0.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
+ floe_python-0.9.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
+ floe_python-0.9.0.dist-info/RECORD,,