floe-python 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/run/entity.py ADDED
@@ -0,0 +1,963 @@
1
+ """One entity run: inputs, per-file checks, split, outputs, `run.json` (Rust: `run/entity/*.rs`, local and S3 storage)."""
2
+
3
+ import io
4
+ import tempfile
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable, Iterator
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass, field, replace
10
+ from datetime import datetime
11
+ from functools import partial
12
+ from itertools import chain
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import polars as pl
17
+ from pyiceberg.table import Table
18
+
19
+ from floe.checks.cast import CAST_ERROR_MESSAGE, cast_column
20
+ from floe.checks.mismatch import (
21
+ MismatchOutcome,
22
+ apply_mismatch,
23
+ declared_names,
24
+ json_input_columns,
25
+ plan_mismatch,
26
+ )
27
+ from floe.checks.normalize import (
28
+ normalize_columns,
29
+ normalize_name,
30
+ output_column_mapping,
31
+ resolve_strategy,
32
+ source_names,
33
+ )
34
+ from floe.checks.rows import (
35
+ ERRORS,
36
+ ROW_INDEX,
37
+ Formatter,
38
+ RowCheck,
39
+ not_null_check,
40
+ split_rows,
41
+ )
42
+ from floe.checks.unique import UniqueResult, UniqueTracker, resolve_unique_keys
43
+ from floe.config.storage import CatalogsConfig, StorageDefinition, StoragesConfig
44
+ from floe.config.types import EntityConfig, with_partition_columns
45
+ from floe.errors import FloeConfigError, FloeIoError, FloeRunError
46
+ from floe.iceberg.sink import (
47
+ existing_table,
48
+ input_hash,
49
+ resolve_target,
50
+ write_iceberg,
51
+ )
52
+ from floe.iceberg.unique import seed_from_table
53
+ from floe.iceberg.write import find_committed
54
+ from floe.pii import apply_pii_masking
55
+ from floe.read.avro import read_avro
56
+ from floe.read.csv import RAGGED_MESSAGE, read_csv
57
+ from floe.read.json import read_json
58
+ from floe.read.orc import read_orc
59
+ from floe.read.parquet import read_parquet
60
+ from floe.read.xlsx import read_xlsx
61
+ from floe.read.xml import read_xml, xml_columns
62
+ from floe.report.entity import build_report, file_report
63
+ from floe.report.summary import run_outcome
64
+ from floe.run.events import NO_EVENTS, Emitter
65
+ from floe.state import (
66
+ CLAIM_TTL_SECONDS,
67
+ Claim,
68
+ EntityState,
69
+ FileState,
70
+ Loaded,
71
+ claim_all,
72
+ claim_inputs,
73
+ promote,
74
+ read_state,
75
+ release,
76
+ renew,
77
+ )
78
+ from floe.storage.archive import archive_input_file
79
+ from floe.storage.inputs import (
80
+ InputFile,
81
+ NoInputsError,
82
+ ResolvedInputs,
83
+ localize_input,
84
+ resolve_inputs,
85
+ suffixes_for_format,
86
+ )
87
+ from floe.storage.local import LocalClient
88
+ from floe.storage.partitioning import (
89
+ advance,
90
+ in_load_order,
91
+ listing_start,
92
+ partition_of,
93
+ source_root,
94
+ window,
95
+ )
96
+ from floe.storage.paths import normalize_local_path
97
+ from floe.storage.resolve import resolve_path, storage_client
98
+ from floe.storage.s3 import S3Client
99
+ from floe.storage.types import StorageClient
100
+ from floe.write.outputs import (
101
+ AcceptedWrite,
102
+ part_uris,
103
+ write_accepted,
104
+ write_rejected,
105
+ )
106
+
107
+ Chunks = Iterator[tuple[int, pl.DataFrame, pl.Series | None]] # fmt: skip # offset, frame, ragged rows
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class EntityRun:
112
+ report: dict[str, Any]
113
+ aborted: bool
114
+
115
+
116
+ def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
117
+ src = entity.source
118
+ match src.format:
119
+ case "csv" | "tsv":
120
+ for c in read_csv(path, src.options, names):
121
+ yield c.offset, c.df, c.ragged
122
+ case "parquet":
123
+ for p in read_parquet(path):
124
+ yield p.offset, p.df, None
125
+ case "avro":
126
+ for a in read_avro(path):
127
+ yield a.offset, a.df, None
128
+ case "orc":
129
+ for o in read_orc(path):
130
+ yield o.offset, o.df, None
131
+ case "xlsx":
132
+ yield 0, read_xlsx(path, src.options), None
133
+ case "xml":
134
+ for x in read_xml(path, src.options, entity.schema_.columns[: len(names)]):
135
+ yield x.offset, x.df, None
136
+ case "json": # #998
137
+ for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode): # fmt: skip
138
+ yield j.offset, j.df, None
139
+ case other:
140
+ raise FloeRunError(
141
+ f"entity.name={entity.name} unsupported source format: {other}"
142
+ )
143
+
144
+
145
+ def _read_rule(fmt: str, message: str) -> str:
146
+ """Rust's `FileReadError.rule`; the readers carry only the message (the XLSX ones prefix the rule)."""
147
+ head, _, _ = message.partition(": ")
148
+ if fmt == "xlsx" and head.startswith("xlsx_"):
149
+ return head
150
+ if fmt == "xml" and message.startswith(
151
+ ("invalid selector", "duplicate xml selector")
152
+ ):
153
+ return "xml_selector_invalid"
154
+ if fmt == "json": # Rust `JsonReadError.rule`
155
+ if message.startswith(("invalid selector", "duplicate json selector")):
156
+ return "json_selector_invalid"
157
+ if message.startswith("non-scalar value for selector"):
158
+ return "json_selector_non_scalar"
159
+ return "json_parse_error"
160
+ return "xml_parse_error" if fmt == "xml" else f"{fmt}_read_error"
161
+
162
+
163
+ def _read_message(fmt: str, message: str) -> str:
164
+ head, _, rest = message.partition(": ")
165
+ return rest if fmt == "xlsx" and head.startswith("xlsx_") else message
166
+
167
+
168
+ def _failed_file(
169
+ entity: EntityConfig,
170
+ uri: str,
171
+ declared: int,
172
+ plan: MismatchOutcome | None,
173
+ error: FloeIoError,
174
+ rejected_path: str | None,
175
+ ) -> dict[str, Any]:
176
+ """A file that could not be read: `rejected` (`aborted` under abort), no rows."""
177
+ fmt = entity.source.format
178
+ action = "aborted" if entity.policy.severity == "abort" else "rejected_file"
179
+ base = plan.report if plan else {}
180
+ report: dict[str, Any] = {
181
+ "declared_columns_count": declared,
182
+ "input_columns_count": base.get("input_columns_count", 0),
183
+ "missing_columns": base.get("missing_columns", []),
184
+ "extra_columns": base.get("extra_columns", []),
185
+ "mismatch_action": action,
186
+ "error": {
187
+ "rule": _read_rule(fmt, str(error)),
188
+ "message": f"entity.name={entity.name} {_read_message(fmt, str(error))}",
189
+ },
190
+ }
191
+ if "warning" in base:
192
+ report["warning"] = base["warning"]
193
+ outcome = MismatchOutcome(
194
+ report, False, False, plan.warnings if plan else 0, 1 + (plan.errors if plan else 0),
195
+ [], [], False, False,
196
+ ) # fmt: skip
197
+ out = file_report(entity, uri, 0, 0, [], outcome, rejected_path)
198
+ out["status"] = "aborted" if action == "aborted" else "rejected"
199
+ return out
200
+
201
+
202
+ def _rejected(plan: "_Plan", severity: str) -> tuple[str, StorageClient]:
203
+ """The rejected sink's URI and client."""
204
+ if plan.rejected is None:
205
+ raise FloeConfigError(f"sink.rejected is required for {severity} severity")
206
+ return plan.rejected
207
+
208
+
209
+ def _put(plan: "_Plan", severity: str, name: str, body: bytes) -> str:
210
+ """Write `<rejected>/<name>`; the report shows a local target as a path, as Rust."""
211
+ base, client = _rejected(plan, severity)
212
+ uri = f"{base.rstrip('/')}/{name}"
213
+ client.write(uri, body)
214
+ return uri.removeprefix("local://")
215
+
216
+
217
+ def _copy_raw(plan: "_Plan", source: Path, severity: str, name: str) -> str:
218
+ # ponytail: the raw file is read into memory for the put; stream it (upload_file) if rejected files get large
219
+ return _put(plan, severity, name, source.read_bytes())
220
+
221
+
222
+ def _error_report(plan: "_Plan", stem: str, rows: list[tuple[int, str]]) -> str:
223
+ """`<stem>_reject_errors.json`, Rust's `write_error_report`."""
224
+ items = ",".join(f'{{"row_index":{i},"errors":{e}}}' for i, e in rows)
225
+ return _put(plan, plan.entity.policy.severity, f"{stem}_reject_errors.json", f"[{items}]".encode()) # fmt: skip
226
+
227
+
228
+ @dataclass
229
+ class _Plan:
230
+ """Per-entity values every file needs."""
231
+
232
+ entity: EntityConfig
233
+ config_dir: Path
234
+ formatter: Formatter
235
+ strategy: str | None
236
+ names: list[str]
237
+ out_map: dict[str, str]
238
+ sources: dict[str, str]
239
+ required: list[str]
240
+ tracker: UniqueTracker
241
+ pii_map: dict[str, str]
242
+ rejected_writes: int = 0
243
+ prechecked: set[str] = field(
244
+ default_factory=set[str]
245
+ ) # files the precheck settles (unreadable header, rejected or aborted mismatch); Rust settles them before it reads any file
246
+ archive: tuple[str, StorageClient] | None = None # Rust `archive_enabled()`
247
+ to_archive: list[InputFile] = field(default_factory=list[InputFile]) # fmt: skip # moved after the sink commit (#914)
248
+ run_id: str = ""
249
+ input_hash: str = (
250
+ "" # of the claimed files; names the rejected parts so a retry overwrites them
251
+ )
252
+ rejected: tuple[str, StorageClient] | None = None # `sink.rejected` URI and client
253
+ table: Table | None = (
254
+ None # the Iceberg target whose keys seed the tracker (append)
255
+ )
256
+ merge_key: str | None = (
257
+ None # the merge key's unique check; its duplicates are rejected even under warn (Rust `enforce_reject`)
258
+ )
259
+ derived: dict[str, list[pl.Expr]] = field(default_factory=dict[str, list[pl.Expr]]) # D28: by input URI # fmt: skip
260
+ root: str | None = None # D29: the source root, when `source.partitioning` is set
261
+
262
+
263
+ def _refuse_overlap(who: str, sink: str, inputs: list[InputFile]) -> None:
264
+ """D34: an input under the accepted Parquet sink would be deleted or re-read as one of its parts."""
265
+
266
+ def real(
267
+ uri: str,
268
+ ) -> str: # a local path through a symlink is judged by where it lands
269
+ return Path(uri.removeprefix("local://")).resolve().as_posix() if uri.startswith("local://") else uri # fmt: skip
270
+
271
+ root = real(sink).rstrip("/") + "/"
272
+ for f in inputs:
273
+ if real(f.source_uri).startswith(root):
274
+ raise FloeConfigError(f"{who} source file {f.source_uri} lies under sink.accepted.path {sink}; the sink would overwrite or re-read it") # fmt: skip
275
+
276
+
277
+ def _archive(plan: _Plan, file: InputFile) -> str | None:
278
+ """Move the source file to `<archive>/<entity>/<stem>__run-…__src-….<ext>` (Rust `archive_input`).
279
+
280
+ D29: with `source.partitioning`, its partition sub-path is kept: `<archive>/<entity>/yyyy/MM/dd/<batch>/<stem>__run-…`.
281
+ """
282
+ if plan.archive is None:
283
+ return None
284
+ base, client = plan.archive
285
+ if file.source_uri.startswith(f"{base}/"):
286
+ return None # D24: an archived input stays put
287
+ # Rust: copy, then delete the source; the report shows a local target as a path
288
+ sub = file.source_uri.removeprefix(f"{plan.root}/").rpartition("/")[0] if plan.root else "" # fmt: skip
289
+ uri = archive_input_file(client, plan.run_id, plan.entity.name, lambda rel: f"{base}/{rel}", file, sub) # fmt: skip
290
+ return uri.removeprefix("local://")
291
+
292
+
293
+ def _with_archived(e: EntityConfig, archive: tuple[str, StorageClient], source: Callable[[], ResolvedInputs]) -> ResolvedInputs: # fmt: skip
294
+ """D24: a full refresh reads `<archive>/<entity>/` first (by name; D29: in load order), then the source inputs; either may be empty, not both.
295
+
296
+ Only "no inputs matched" counts as empty: any other listing error fails the refresh rather than rebuild from part of the inputs.
297
+ """
298
+ base, client = archive
299
+ p = e.source.partitioning
300
+ path = e.name if base.startswith("local://") else f"{base}/{e.name}/"
301
+ try:
302
+ old = resolve_inputs(Path(base.removeprefix("local://")), path, e.source.format, e.name, e.source.storage or "local", client, recursive=p is not None) # fmt: skip
303
+ except NoInputsError:
304
+ return (
305
+ source()
306
+ ) # nothing archived: the source's "no input files matched" stands
307
+ if p is not None: # D29: the archive keeps the partition sub-paths
308
+ old = in_load_order(p, e.name, f"{base}/{e.name}", old, archived=True)
309
+ try:
310
+ new = source()
311
+ except NoInputsError: # no new source file: rebuild from the archive alone
312
+ return old
313
+ seen = set(old.listed) # an S3 source prefix also lists an archive nested under it
314
+ files = [f for f in new.files if f.source_uri not in seen]
315
+ return ResolvedInputs(old.files + files, old.listed + [u for u in new.listed if u not in seen], new.mode) # fmt: skip
316
+
317
+
318
+ def _output(plan: _Plan, df: pl.DataFrame) -> pl.DataFrame:
319
+ """Output column names, then PII masking."""
320
+ df = df.rename(plan.out_map)
321
+ pii = plan.entity.pii
322
+ return apply_pii_masking(df, pii.columns, plan.pii_map) if pii else df
323
+
324
+
325
+ def _typed(
326
+ plan: _Plan, raw: pl.DataFrame, mismatch: MismatchOutcome, derived: list[pl.Expr]
327
+ ) -> tuple[pl.DataFrame, list[RowCheck]]:
328
+ entity, src = plan.entity, plan.entity.source
329
+ # D28 (#913): the derived columns join after the file's own columns are normalised and matched
330
+ df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch).with_columns(
331
+ derived
332
+ )
333
+ df = df.select(
334
+ pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
335
+ for n in plan.names
336
+ )
337
+ columns = entity.schema_.columns
338
+ try:
339
+ casts = [
340
+ cast_column(n, c.column_type, src.cast_mode, df.schema[n])
341
+ for n, c in zip(plan.names, columns, strict=True)
342
+ ]
343
+ typed = df.with_columns(t for t, _ in casts)
344
+ masks = df.with_columns(
345
+ m for _, m in casts
346
+ ) # unlike select, broadcasts constant masks
347
+ except pl.exceptions.InvalidOperationError as err:
348
+ raise FloeRunError(f"entity.name={entity.name} cast failed: {err}") from err
349
+ cast_checks = [
350
+ RowCheck("cast_error", n, CAST_ERROR_MESSAGE, masks[f"_e_cast_{n}"])
351
+ for n, c in zip(plan.names, columns, strict=True)
352
+ if c.column_type != "string"
353
+ ] # fmt: skip
354
+ if entity.policy.severity == "abort" and any(c.mask.any() for c in cast_checks):
355
+ return typed, cast_checks # fmt: skip # Rust: abort-mode cast errors skip not_null and unique
356
+ 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)
360
+ return typed, [*required, *cast_checks, *plan.tracker.check(typed)]
361
+
362
+
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."""
365
+ entity, uri = plan.entity, file.source_uri
366
+ severity = entity.policy.severity
367
+ derived = plan.derived.get(uri, [])
368
+ # D28: the derived columns come last in the schema and are not in the file
369
+ file_names = plan.names[: len(plan.names) - len(derived)]
370
+ derived = [e.alias(n) for e, n in zip(derived, plan.names[len(file_names) :], strict=True)] # fmt: skip
371
+ declared = [n for n in declared_names(entity) if n not in plan.names[len(file_names) :]] # fmt: skip
372
+ chunks = _chunks(entity, path, file_names)
373
+ keys = plan.tracker.snapshot()
374
+
375
+ def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], list[pl.DataFrame]]: # fmt: skip
376
+ # Rust skips the raw copy under PII: the file cannot be masked
377
+ plan.tracker.restore(keys) # the unreadable file leaves no keys behind
378
+ keep = entity.pii is None and entity.sink.rejected is not None
379
+ raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
380
+ return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
381
+
382
+ try:
383
+ if entity.source.format == "xml":
384
+ first, probe = [], xml_columns(path, entity.source.options)
385
+ elif entity.source.format == "json":
386
+ # D40: every record's top-level keys; D41: only a file without records has nothing to compare
387
+ json_keys = json_input_columns(path, entity.source.options.json_mode or "array") # fmt: skip
388
+ first, probe = [], declared if json_keys is None else json_keys
389
+ else: # the first chunk's columns are the input columns the plan compares
390
+ first = [next(chunks)]
391
+ probe = list(first[0][1].columns)
392
+ except FloeIoError as err:
393
+ plan.prechecked.add(uri) # Rust `run_precheck` settles an unreadable header too
394
+ return failed(err, None)
395
+ mismatch = plan_mismatch(entity, declared, probe)
396
+ # D28: the derived columns are declared (through partitioning), not input columns
397
+ mismatch.report["declared_columns_count"] += len(derived)
398
+ if mismatch.rejected or mismatch.aborted:
399
+ plan.prechecked.add(uri)
400
+ raw = _copy_raw(plan, path, "abort" if mismatch.aborted else "reject", file.source_name) # fmt: skip
401
+ plan.to_archive.append(file)
402
+ return file_report(entity, uri, 0, 0, [], mismatch, raw), []
403
+
404
+ rows, bad_total, forced_total = 0, 0, 0
405
+ masks: dict[tuple[str, str, str], list[pl.Series]] = {}
406
+ accepted: list[pl.DataFrame] = []
407
+ rejected: list[pl.DataFrame] = []
408
+ errors: list[tuple[int, str]] = []
409
+ try:
410
+ for offset, raw_df, ragged in chain(first, chunks):
411
+ typed, checks = _typed(plan, raw_df, mismatch, derived)
412
+ if ragged is not None and ragged.any():
413
+ checks.insert(0, RowCheck("csv_read_error", "", RAGGED_MESSAGE, ragged))
414
+ good, bad = split_rows(typed, checks, plan.formatter, plan.sources)
415
+ bad = bad.with_columns(pl.col(ROW_INDEX) + offset)
416
+ rows += typed.height
417
+ bad_total += bad.height
418
+ for c in checks:
419
+ masks.setdefault((c.rule, c.column, c.message), []).append(c.mask)
420
+ errors += zip(bad[ROW_INDEX].to_list(), bad[ERRORS].to_list(), strict=True)
421
+ forced = next((c.mask for c in checks if c.rule == "unique" and c.column == plan.merge_key), None) # fmt: skip
422
+ if severity == "warn" and forced is not None:
423
+ forced = forced.fill_null(False)
424
+ forced_total += int(forced.sum())
425
+ typed = typed.filter(~forced)
426
+ accepted.append(good if severity == "reject" else typed)
427
+ if severity == "reject" and bad.height:
428
+ rejected.append(bad)
429
+ except FloeIoError as err:
430
+ return failed(err, mismatch)
431
+
432
+ merged = [RowCheck(r, c, m, pl.concat(s)) for (r, c, m), s in masks.items()]
433
+ rejected_path = errors_path = None
434
+ if bad_total and severity == "reject":
435
+ mode = entity.sink.write_mode
436
+ if mode == "overwrite" and plan.rejected_writes:
437
+ mode = "append"
438
+ plan.rejected_writes += 1
439
+ target, client = _rejected(plan, "reject")
440
+ written = write_rejected(_output(plan, pl.concat(rejected)), client, target, mode, f"{plan.input_hash}:{file.source_uri}") # fmt: skip
441
+ rejected_path = written.removeprefix("local://")
442
+ elif bad_total and severity == "abort":
443
+ rejected_path = _copy_raw(plan, path, "abort", file.source_name)
444
+ if bad_total and entity.sink.rejected and severity != "reject":
445
+ errors_path = _error_report(plan, file.source_stem, errors)
446
+ plan.to_archive.append(file)
447
+ 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
449
+
450
+
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()}))
464
+
465
+
466
+ def _report_names(
467
+ r: UniqueResult, key: list[str], runtime: dict[str, str]
468
+ ) -> UniqueResult:
469
+ """The report names the schema columns, in `columns` and in the sample values."""
470
+ back = {runtime.get(c, c): c for c in key}
471
+ samples = [replace(x, values={back.get(k, k): v for k, v in x.values.items()}) for x in r.samples] # fmt: skip
472
+ return replace(r, columns=key, samples=samples)
473
+
474
+
475
+ RENEW_INTERVAL_S = float(min(max(CLAIM_TTL_SECONDS // 3, 1), 300))
476
+ _SEPARATORS = "/\\"
477
+
478
+
479
+ def _parent(value: str) -> str | None:
480
+ i = max(value.rfind(s) for s in _SEPARATORS)
481
+ return None if i < 0 else value[: max(i, 1)]
482
+
483
+
484
+ def _source_root(raw: str, fmt: str, resolved: Path | None) -> str:
485
+ """Rust `derive_source_root`: the directory the default state lives under."""
486
+ trimmed = raw.rstrip(_SEPARATORS)
487
+ if not trimmed:
488
+ return ""
489
+ cut = next((i for i, c in enumerate(trimmed) if c in "*?["), -1)
490
+ if cut >= 0:
491
+ prefix = trimmed[:cut]
492
+ done = prefix if not prefix or prefix[-1] in _SEPARATORS else _parent(prefix) or prefix # fmt: skip
493
+ return done.rstrip(_SEPARATORS)
494
+ if resolved and resolved.is_dir(): # a remote source has no local path to probe
495
+ return trimmed
496
+ if (resolved and resolved.is_file()) or trimmed.lower().endswith(
497
+ tuple(suffixes_for_format(fmt))
498
+ ):
499
+ # #813: a bare filename lives in the config dir (Rust 0.7 used the file itself)
500
+ return (_parent(trimmed) or ".").rstrip(_SEPARATORS)
501
+ return trimmed
502
+
503
+
504
+ def state_uri(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None = None) -> tuple[str, StorageClient]: # fmt: skip
505
+ """`state.path` if set (a URI on the source storage, else local), else `<source root>/.floe/state/<domain>/<name>/state.json` on the source storage (Rust `resolve_entity_state_path`)."""
506
+ src, field = entity.source, f"entity.name={entity.name} entity.state.path"
507
+ custom = entity.state and entity.state.path
508
+ if custom and "://" not in custom:
509
+ return f"local://{normalize_local_path(config_dir / custom)}", LocalClient()
510
+ if not custom:
511
+ source_uri, _ = resolve_path(storages, config_dir, src.storage, src.path, field)
512
+ local = source_uri.startswith("local://")
513
+ root = _source_root(src.path, src.format, Path(source_uri.removeprefix("local://")) if local else None) # fmt: skip
514
+ ident = f"{entity.domain}/{entity.name}" if entity.domain and entity.id != entity.name else entity.name # fmt: skip
515
+ tail = f".floe/state/{ident}/state.json"
516
+ custom = f"{root}/{tail}" if root and root != "." else tail
517
+ uri, definition = resolve_path(storages, config_dir, src.storage, custom, field)
518
+ return uri, storage_client(definition)
519
+
520
+
521
+ def skipped_report(uri: str, reason: str, warning: str | None = None) -> dict[str, Any]:
522
+ mismatch: dict[str, Any] = {
523
+ "declared_columns_count": 0,
524
+ "input_columns_count": 0,
525
+ "missing_columns": [],
526
+ "extra_columns": [],
527
+ "mismatch_action": "none",
528
+ }
529
+ if warning:
530
+ mismatch["warning"] = warning
531
+ return {
532
+ "input_file": uri,
533
+ "status": "skipped",
534
+ "row_count": 0,
535
+ "accepted_count": 0,
536
+ "rejected_count": 0,
537
+ "skip_reason": reason,
538
+ "mismatch": mismatch,
539
+ "output": {
540
+ "accepted_path": None,
541
+ "rejected_path": None,
542
+ "errors_path": None,
543
+ "archived_path": None,
544
+ },
545
+ "validation": {"errors": 0, "warnings": int(bool(warning)), "rules": []},
546
+ }
547
+
548
+
549
+ def skipped_reports(
550
+ entity: EntityConfig,
551
+ already: list[tuple[InputFile, FileState]],
552
+ active: list[str],
553
+ ) -> list[dict[str, Any]]:
554
+ out: list[dict[str, Any]] = []
555
+ for f, rec in already:
556
+ if (rec.size, rec.mtime) == (f.source_size, f.source_mtime):
557
+ out.append(skipped_report(f.source_uri, "already_ingested"))
558
+ continue
559
+ msg = (
560
+ f"entity.name={entity.name} incremental_mode=file skipping previously ingested file with changed metadata: {f.source_uri} "
561
+ f"(recorded size={rec.size}, mtime={rec.mtime}; current size={f.source_size}, mtime={f.source_mtime})"
562
+ ) # fmt: skip
563
+ out.append(skipped_report(f.source_uri, "already_ingested_changed", msg))
564
+ out += [
565
+ skipped_report(
566
+ u, "active_claim",
567
+ f"entity.name={entity.name} incremental_mode=file skipping file claimed by another active run: {u}",
568
+ )
569
+ for u in active
570
+ ] # fmt: skip
571
+ return out
572
+
573
+
574
+ def _emit_skipped(emit: Emitter, entity: str, reports: list[dict[str, Any]]) -> None:
575
+ for r in reports:
576
+ emit("file_finished", entity=entity, input=r["input_file"], status="skipped",
577
+ skip_reason=r["skip_reason"], rows=0, accepted=0, rejected=0, elapsed_ms=0) # fmt: skip
578
+
579
+
580
+ class Incremental:
581
+ """Claims the entity's inputs, renews the claims while it runs, then promotes or releases them."""
582
+
583
+ def __init__(self, entity: EntityConfig, config_dir: Path, run_id: str, full_refresh: bool = False, storages: StoragesConfig | None = None) -> None: # fmt: skip
584
+ self.entity, self.config_dir, self.run_id = entity, config_dir, run_id
585
+ self.storages = storages
586
+ self.full_refresh = full_refresh
587
+ self.client: StorageClient = LocalClient()
588
+ self.uri = ""
589
+ self.claimed: Loaded | None = None
590
+ self.expired: dict[str, Claim] = {} # the dead runs' claims this run took over
591
+ # the file reports, set once the sink step is done (#915)
592
+ self.committed: list[dict[str, Any]] | None = None
593
+ self.advance: Callable[[EntityState, list[str]], None] | None = None # D30
594
+ self._stop = threading.Event()
595
+ self._thread: threading.Thread | None = None
596
+
597
+ def claim(self, inputs: list[InputFile], emit: Emitter) -> tuple[list[InputFile], list[dict[str, Any]]]: # fmt: skip
598
+ """The inputs left to process and the reports of the skipped ones; `row` mode and a full refresh never skip (Rust 0.7)."""
599
+ e = self.entity
600
+ if e.incremental_mode != "file":
601
+ return inputs, []
602
+ self.uri, self.client = state_uri(e, self.config_dir, self.storages)
603
+ if self.full_refresh:
604
+ self._hold(claim_all(self.client, self.uri, e.name, self.run_id, inputs))
605
+ return inputs, []
606
+ got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge")) # fmt: skip
607
+ reports = skipped_reports(e, got.already_processed, got.active_claims)
608
+ _emit_skipped(emit, e.name, reports)
609
+ self.expired = got.expired
610
+ if got.claimed:
611
+ self._hold(got.claimed)
612
+ return got.pending, reports
613
+
614
+ def skip_committed(self, table: Table | None, pending: list[InputFile], emit: Emitter) -> list[dict[str, Any]]: # fmt: skip
615
+ """#545 retry guard (append): the reports of the pending inputs a dead run already committed, before any is read.
616
+
617
+ `file` mode re-derives each dead run's batch from its expired claims (by `run_id`, with their size and mtime),
618
+ so a retry that also holds new inputs still matches; it looks back to that run's claim. Other modes match the
619
+ whole set.
620
+ """
621
+ e = self.entity
622
+ if table is None or not pending or self.full_refresh:
623
+ return []
624
+ if e.incremental_mode != "file":
625
+ done = pending if find_committed(table, input_hash(e, pending)) else []
626
+ return self._skipped(done, emit)
627
+ ours = {f.source_uri for f in pending}
628
+ runs: dict[str, dict[str, Claim]] = {}
629
+ for u, c in self.expired.items():
630
+ runs.setdefault(c.run_id, {})[u] = c
631
+ committed: set[str] = set()
632
+ for claims in runs.values():
633
+ if ours.isdisjoint(claims):
634
+ continue # new inputs, or a reload after `floe state reset`
635
+ since = min(datetime.fromisoformat(c.acquired_at) for c in claims.values())
636
+ batch = [InputFile(u, "", "", c.size, c.mtime) for u, c in claims.items()]
637
+ found = find_committed(table, input_hash(e, batch), since)
638
+ # fail closed; `run_entity` releases, which puts the dead runs' claims back so the next run fails too
639
+ if found is None:
640
+ raise FloeRunError(
641
+ f"entity.name={e.name} cannot tell whether a run that died after claiming its inputs at {since} "
642
+ "committed them: the table's snapshots since then were expired (retention floor: 7 days and at least "
643
+ "10 snapshots); run `floe state reset` for the entity, then rerun"
644
+ ) # fmt: skip
645
+ if found:
646
+ committed |= claims.keys() & ours
647
+ # promote now, so a later failure of the other inputs can't release them
648
+ if committed and self.claimed:
649
+ promote(self.client, self.uri, self.run_id, self.claimed, only=committed, advance=self.advance) # fmt: skip
650
+ return self._skipped([f for f in pending if f.source_uri in committed], emit)
651
+
652
+ def _skipped(self, files: list[InputFile], emit: Emitter) -> list[dict[str, Any]]:
653
+ reports = [skipped_report(f.source_uri, "already_committed") for f in files]
654
+ _emit_skipped(emit, self.entity.name, reports)
655
+ return reports
656
+
657
+ def _hold(self, claimed: Loaded) -> None:
658
+ self.claimed = claimed
659
+ self._thread = threading.Thread(target=self._beat, daemon=True)
660
+ self._thread.start()
661
+
662
+ def _beat(self) -> None:
663
+ while not self._stop.wait(RENEW_INTERVAL_S):
664
+ with suppress(Exception): # Rust ignores renewal errors
665
+ if self.claimed:
666
+ renew(self.client, self.uri, self.run_id, self.claimed)
667
+
668
+ def _end(self) -> Loaded | None:
669
+ self._stop.set()
670
+ if self._thread:
671
+ self._thread.join()
672
+ claimed, self.claimed = self.claimed, None
673
+ return claimed
674
+
675
+ def release(self) -> None:
676
+ """The dead runs' claims this run took over go back: the next run checks them again (#545)."""
677
+ if claimed := self._end():
678
+ # D30: an older day's released file stays listed for a retry
679
+ release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
680
+
681
+ def finish(self, files: list[dict[str, Any]]) -> None:
682
+ """Promote unless the entity failed (Rust commits on success, warnings and rejected).
683
+
684
+ #918: an abort still writes the files before it (Rust 0.7), so it promotes those and releases the rest;
685
+ under `overwrite` it releases them all, as the retry rebuilds the output from every file.
686
+ """
687
+ status, _ = run_outcome([f["status"] for f in files])
688
+ if status == "failed" or (status == "aborted" and self.entity.sink.write_mode == "overwrite"): # fmt: skip
689
+ return self.release()
690
+ if claimed := self._end():
691
+ only = {f["input_file"] for f in files if f["status"] != "aborted"} if status == "aborted" else None # fmt: skip
692
+ promote(self.client, self.uri, self.run_id, claimed, self.full_refresh, only, self.advance) # fmt: skip
693
+ if (
694
+ only is not None
695
+ ): # D30: the aborted day's released files stay listed for a retry
696
+ release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
697
+
698
+
699
+ def resolve_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None) -> tuple[str, LocalClient | S3Client]: # fmt: skip
700
+ """`source.path` as a URI, and the client of its storage."""
701
+ src = entity.source
702
+ uri, definition = resolve_path(storages, config_dir, src.storage, src.path, f"entity.name={entity.name} source.storage") # fmt: skip
703
+ return uri, storage_client(definition)
704
+
705
+
706
+ def archive_location(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None) -> tuple[str, StorageDefinition | None] | None: # fmt: skip
707
+ """`sink.archive` as a URI (no trailing `/`) and its storage definition."""
708
+ archive, src = entity.sink.archive, entity.source
709
+ if archive is None:
710
+ return None
711
+ uri, definition = resolve_path(storages, config_dir, archive.storage or src.storage, archive.path, f"entity.name={entity.name} sink.archive.storage") # fmt: skip
712
+ return uri.rstrip("/"), definition
713
+
714
+
715
+ def _derived(entity: EntityConfig, source_uri: str, archive: str | None, files: list[InputFile]) -> dict[str, list[pl.Expr]]: # fmt: skip
716
+ """D28: each input's date (and batch) column values, from its path."""
717
+ p = entity.source.partitioning
718
+ if p is None:
719
+ return {}
720
+ root, out = source_root(source_uri), dict[str, list[pl.Expr]]()
721
+ for f in files:
722
+ archived = archive and f.source_uri.startswith(f"{archive}/") # D29: a full refresh # fmt: skip
723
+ part = partition_of(p, entity.name, f"{archive}/{entity.name}" if archived else root, f.source_uri) # fmt: skip
724
+ out[f.source_uri] = [pl.lit(part.when).alias(p.date_column)]
725
+ if p.batch_column:
726
+ out[f.source_uri].append(
727
+ pl.lit(part.batch, pl.String).alias(p.batch_column)
728
+ )
729
+ return out
730
+
731
+
732
+ def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None, uri: str, client: StorageClient, full_refresh: bool = False) -> ResolvedInputs: # fmt: skip
733
+ """The source inputs; a local path keeps its globs relative to the config.
734
+
735
+ D31: the archive is never listed as source input; only a full refresh reads it, explicitly (D24).
736
+ D30: `file` mode with partitioning lists only from the state's window.
737
+ """
738
+ src, archive = entity.source, archive_location(entity, config_dir, storages)
739
+ p, root, state = src.partitioning, source_root(uri), None
740
+ if p and entity.incremental_mode == "file" and not full_refresh:
741
+ at, state_client = state_uri(entity, config_dir, storages)
742
+ state = read_state(state_client, at, entity.name)
743
+ start = window(p, entity.name, root, state) if p else None
744
+ after = listing_start(p, start) if p and start else None
745
+
746
+ def source() -> ResolvedInputs:
747
+ inputs = resolve_inputs(
748
+ config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
749
+ src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
750
+ recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
751
+ after=f"{root}/{after}" if after else "",
752
+ ) # fmt: skip
753
+ if not p:
754
+ return inputs
755
+ inputs = in_load_order(p, entity.name, root, inputs, start=start)
756
+ if start is None or state is None:
757
+ return inputs
758
+ late = f"entity.name={entity.name} incremental_mode=file lists source.partitioning dates from {start} (watermark {state.watermark}, lookback_days {p.lookback_days}); a late file in an older day is not ingested until --full-refresh" # fmt: skip
759
+ return replace(inputs, warning=late)
760
+
761
+ if full_refresh and archive and entity.incremental_mode in ("none", "archive"):
762
+ return _with_archived(entity, (archive[0], storage_client(archive[1])), source)
763
+ return source()
764
+
765
+
766
+ def run_entity(
767
+ entity: EntityConfig,
768
+ config_dir: Path,
769
+ spec_version: str,
770
+ formatter: Formatter = "json",
771
+ storages: StoragesConfig | None = None,
772
+ emit: Emitter = NO_EVENTS,
773
+ full_refresh: bool = False,
774
+ catalogs: CatalogsConfig | None = None,
775
+ listed: ResolvedInputs | None = None,
776
+ ) -> EntityRun:
777
+ """Run one entity on local or S3 storage, with Parquet or Iceberg sinks; `config_dir` is absolute and normalised."""
778
+ inc = Incremental(entity, config_dir, emit.run_id, full_refresh, storages)
779
+ try:
780
+ run = _run_entity(
781
+ entity,
782
+ config_dir,
783
+ spec_version,
784
+ formatter,
785
+ storages,
786
+ emit,
787
+ inc,
788
+ catalogs,
789
+ listed,
790
+ )
791
+ except BaseException:
792
+ if inc.committed is None:
793
+ inc.release() # a failed entity leaves no claims behind
794
+ else: # #915: the sink holds the rows, so a release would let a rerun write them again
795
+ inc.finish(inc.committed)
796
+ raise
797
+ inc.finish(run.report["files"])
798
+ return run
799
+
800
+
801
+ def _run_entity(
802
+ entity: EntityConfig,
803
+ config_dir: Path,
804
+ spec_version: str,
805
+ formatter: Formatter,
806
+ storages: StoragesConfig | None,
807
+ emit: Emitter,
808
+ inc: Incremental,
809
+ catalogs: CatalogsConfig | None,
810
+ listed: ResolvedInputs | None,
811
+ ) -> EntityRun:
812
+ entity = with_partition_columns(
813
+ entity
814
+ ) # D28: the derived columns are schema columns
815
+ sink = entity.sink
816
+ if sink.accepted.format not in ("parquet", "iceberg"):
817
+ raise FloeRunError(f"entity.name={entity.name} unsupported sink format: {sink.accepted.format}") # fmt: skip
818
+ iceberg = sink.accepted.format == "iceberg"
819
+ who = f"entity.name={entity.name}"
820
+ source_uri, client = resolve_source(entity, config_dir, storages)
821
+ target = resolve_target(storages, catalogs, config_dir, entity) if iceberg else None
822
+ # PyIceberg writes an Iceberg table's files, not Floe's storage clients
823
+ base, accepted = resolve_path(storages, config_dir, sink.accepted.storage, sink.accepted.path, f"{who} sink.accepted.storage") # fmt: skip
824
+ rejected = sink.rejected and resolve_path(storages, config_dir, sink.rejected.storage, sink.rejected.path, f"{who} sink.rejected.storage") # fmt: skip
825
+ strategy = resolve_strategy(entity.schema_)
826
+ columns = entity.schema_.columns
827
+ names = source_names(columns, strategy)
828
+ runtime = {c.name: n for c, n in zip(columns, names, strict=True)}
829
+ keys = resolve_unique_keys(
830
+ entity.schema_.unique_keys,
831
+ [c.name for c in columns if c.unique],
832
+ entity.schema_.primary_key,
833
+ )
834
+ primary = {runtime.get(k.strip(), k.strip()) for k in entity.schema_.primary_key or []} # fmt: skip
835
+ plan = _Plan(
836
+ entity, config_dir, formatter, strategy, names,
837
+ output_column_mapping(columns, strategy),
838
+ {n: c.source for n, c in zip(names, columns, strict=True) if c.source},
839
+ [n for n, c in zip(names, columns, strict=True) if c.nullable is False or n in primary],
840
+ UniqueTracker([[runtime.get(k, k) for k in key] for key in keys]),
841
+ {
842
+ c.name: n
843
+ for c in columns
844
+ if (n := c.name if c.source else normalize_name(c.name, strategy)) != c.name
845
+ },
846
+ run_id=emit.run_id, rejected=rejected and (rejected[0], storage_client(rejected[1])),
847
+ ) # fmt: skip
848
+ location = archive_location(entity, config_dir, storages)
849
+ if location and entity.incremental_mode in ("none", "archive"):
850
+ plan.archive = location[0], storage_client(location[1])
851
+ plan.root = source_root(source_uri) if entity.source.partitioning else None
852
+ if sink.write_mode in ("merge_scd1", "merge_scd2") and entity.schema_.primary_key:
853
+ pk = [runtime.get(k.strip(), k.strip()) for k in entity.schema_.primary_key]
854
+ plan.merge_key = (
855
+ pk[0] if len(pk) == 1 else f"[{','.join(pk)}]"
856
+ ) # the tracker's check name
857
+ 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:
866
+ plan.table = existing_table(target) # fmt: skip # Rust `seed_unique_tracker`, and the retry guard
867
+
868
+ def source() -> ResolvedInputs:
869
+ # `listed`: the run's pre-check already listed the source (#864)
870
+ return (
871
+ listed
872
+ if listed is not None
873
+ else list_source(
874
+ entity, config_dir, storages, source_uri, client, inc.full_refresh
875
+ )
876
+ )
877
+
878
+ inputs = source()
879
+ if inputs.warning: # D30
880
+ emit("log", log_level="warn", code=None, message=inputs.warning, entity=entity.name, input=None) # fmt: skip
881
+ if entity.source.partitioning and entity.incremental_mode == "file":
882
+ dated = entity.source.partitioning, entity.name, source_root(source_uri)
883
+ inc.advance = partial(advance, *dated)
884
+ archive = plan.archive # a full refresh lists it too
885
+ plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
886
+ # D34 (#931): same store only (a bucket name repeats across endpoints); before the claim, so a refusal leaves nothing to release
887
+ if target is None and getattr(client, "endpoint", None) == getattr(accepted_client, "endpoint", None): # fmt: skip
888
+ _refuse_overlap(who, base, inputs.files)
889
+ pending, files = inc.claim(inputs.files, emit)
890
+ if done := inc.skip_committed(plan.table, pending, emit):
891
+ files += done
892
+ skip = {r["input_file"] for r in done}
893
+ # `archive` mode: the archive is the marker
894
+ plan.to_archive += [f for f in pending if f.source_uri in skip]
895
+ pending = [f for f in pending if f.source_uri not in skip]
896
+ plan.input_hash = input_hash(entity, pending)
897
+ frames: list[pl.DataFrame] = []
898
+ # #545: the snapshot's fingerprint names only the files it holds in full, never an aborted batch
899
+ complete: list[InputFile] = []
900
+ skipped = len(files)
901
+ 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
920
+
921
+ table: dict[str, Any] | None = None
922
+ 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
933
+ 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)
943
+ inc.committed = files # #915: from here on a failure promotes the written files
944
+ # #914: archive only once the sink committed, so a failed commit leaves the inputs in the source (Rust archives before)
945
+ by_uri = {r["input_file"]: r for r in files}
946
+ for f in plan.to_archive:
947
+ by_uri[f.source_uri]["output"]["archived_path"] = _archive(plan, f)
948
+ unique = [_report_names(r, key, runtime) for r, key in zip(plan.tracker.results(), keys, strict=True)] # fmt: skip
949
+ report = build_report(
950
+ entity,
951
+ spec_version,
952
+ inputs,
953
+ files,
954
+ write,
955
+ source_uri=source_uri,
956
+ accepted_uri=target.accepted_uri if target else base,
957
+ rejected_uri=rejected[0] if rejected else None,
958
+ unique=unique,
959
+ table_root_uri=target.location if target and table else None,
960
+ table=table,
961
+ merge=merge,
962
+ )
963
+ return EntityRun(report, any(f["status"] == "aborted" for f in files))