floe-python 0.8.7__py3-none-any.whl → 0.8.9__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/__init__.py CHANGED
@@ -136,8 +136,7 @@ def _profile(
136
136
  profile = parse_profile(path)
137
137
  except FloeError as e:
138
138
  raise FloeConfigError(f"failed to load profile {_debug(path)}: {e}") from e
139
- return ProfileConfig(profile.name, profile.runner, profile.variables | (variables or {}),
140
- profile.catalogs, profile.storages, profile.lineage) # fmt: skip
139
+ return replace(profile, variables=profile.variables | (variables or {}))
141
140
 
142
141
 
143
142
  def _load(config_path: str, profile: ProfileConfig | None = None) -> tuple[Path, _Root]:
floe/_cli_output.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """`floe run` output: run events as log lines and the run summary (Rust: `crates/floe-cli/src/logging.rs`, `output.rs`)."""
2
2
 
3
3
  import json
4
+ import threading
4
5
  import time
5
6
  from typing import Any
6
7
 
@@ -73,18 +74,20 @@ class Console:
73
74
  def __init__(self, log_format: str, run_id: str) -> None:
74
75
  self.log_format, self.run_id = log_format, run_id
75
76
  self.started = False
76
- self.timings: list[dict[str, int]] = [] # per entity: input -> elapsed_ms
77
+ self.timings: dict[
78
+ str, dict[str, int]
79
+ ] = {} # entity name -> input -> elapsed_ms
80
+ self._lock = threading.Lock() # entities may run in parallel threads
77
81
 
78
82
  def __call__(self, event: dict[str, Any]) -> None:
79
83
  kind = event["event"]
80
- self.started |= kind == "run_started"
81
- if kind == "entity_started":
82
- self.timings.append({})
83
- elif kind == "file_finished" and self.timings:
84
- self.timings[-1][event["input"]] = event["elapsed_ms"]
85
- if self.log_format != "off":
86
- fmt = event_json if self.log_format == "json" else event_text
87
- print(fmt(event), flush=True)
84
+ with self._lock:
85
+ self.started |= kind == "run_started"
86
+ if kind == "file_finished":
87
+ self.timings.setdefault(event["entity"], {})[event["input"]] = event["elapsed_ms"] # fmt: skip
88
+ if self.log_format != "off":
89
+ fmt = event_json if self.log_format == "json" else event_text
90
+ print(fmt(event), flush=True)
88
91
 
89
92
  def failed(self, err: FloeError, config: str | None) -> None:
90
93
  """Rust's `emit_failed_run_events`, after a `run_started` for `config` when the run never started.
@@ -154,7 +157,7 @@ def run_output(
154
157
  outcome: RunOutcome,
155
158
  entities: list[EntityConfig],
156
159
  mode: str,
157
- timings: list[dict[str, int]],
160
+ timings: dict[str, dict[str, int]],
158
161
  ) -> str:
159
162
  """Rust's `format_run_output`; `mode` is `default`, `quiet` or `verbose`."""
160
163
  run_dir = outcome.run_dir # a URI on remote storage: join with `/`, never `Path`
@@ -166,7 +169,17 @@ def run_output(
166
169
  results = dict.fromkeys(("files_total", "rows_total", "accepted_total", "rejected_total", "files_skipped"), 0) # fmt: skip
167
170
  status, exit_code = "success", 0
168
171
  else:
169
- blocks = [_entity(r, t, mode == "verbose") for r, t in zip(outcome.entity_reports, timings, strict=True)] # fmt: skip
172
+ names = {
173
+ e.id: e.name for e in entities
174
+ } # reports carry the id, `file_finished` events the name
175
+
176
+ def shown(report: dict[str, Any]) -> list[str]:
177
+ ident: str = report["entity"]["name"]
178
+ return _entity(
179
+ report, timings.get(names.get(ident, ident), {}), mode == "verbose"
180
+ )
181
+
182
+ blocks = [shown(r) for r in outcome.entity_reports]
170
183
  results, run = outcome.summary["results"], outcome.summary["run"]
171
184
  status, exit_code = run["status"], run["exit_code"]
172
185
  lines = ["DRY RUN MODE - No actual execution performed"] if outcome.dry_run is not None else [] # fmt: skip
floe/checks/mismatch.py CHANGED
@@ -119,19 +119,34 @@ def apply_mismatch(df: pl.DataFrame, plan: MismatchOutcome) -> pl.DataFrame:
119
119
  return df
120
120
 
121
121
 
122
- def json_input_columns(path: Path, mode: str = "array") -> list[str] | None:
123
- """Sorted top-level keys of every record, read exactly as `read_json` reads them.
122
+ def json_probe(
123
+ path: Path, mode: str, limit: int
124
+ ) -> tuple[list[str] | None, list[tuple[str, Any]] | None]:
125
+ """`json_input_columns` plus the file's parsed records when it has at most `limit` (else `None`).
124
126
 
125
- D40: the union over all records, so a key only a later record carries is still an input
126
- column (Rust 0.7 probed the first object only). `None` for a file without records (D41), which
127
- differs from records that carry no keys (`[]`): those are still checked for missing columns.
127
+ A file that fits one chunk is then parsed once: the caller reads the records it got here.
128
128
  """
129
129
  if mode not in ("array", "ndjson"):
130
130
  raise FloeIoError(
131
131
  f"unsupported source.options.json_mode={mode} (allowed: array, ndjson)"
132
132
  )
133
- # ponytail: the file is parsed twice (probe, then read); fold the probe into the read if it costs
134
133
  keys: set[str] | None = None
135
- for _, record in json_records(path, mode):
136
- keys = (keys or set[str]()) | {str(k) for k in record}
137
- return None if keys is None else sorted(keys)
134
+ head: list[tuple[str, Any]] | None = []
135
+ for located in json_records(path, mode):
136
+ keys = keys or set[str]()
137
+ keys.update(str(k) for k in located[1])
138
+ if head is not None and len(head) < limit:
139
+ head.append(located)
140
+ else: # a longer file is read again, chunk by chunk, so it keeps nothing
141
+ head = None
142
+ return (None if keys is None else sorted(keys)), head
143
+
144
+
145
+ def json_input_columns(path: Path, mode: str = "array") -> list[str] | None:
146
+ """Sorted top-level keys of every record, read exactly as `read_json` reads them.
147
+
148
+ D40: the union over all records, so a key only a later record carries is still an input
149
+ column (Rust 0.7 probed the first object only). `None` for a file without records (D41), which
150
+ differs from records that carry no keys (`[]`): those are still checked for missing columns.
151
+ """
152
+ return json_probe(path, mode, 0)[0]
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,7 +1,7 @@
1
1
  """Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
2
2
 
3
3
  import os
4
- from collections.abc import Callable
4
+ from collections.abc import Callable, Mapping
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from typing import cast
@@ -395,12 +395,21 @@ def validate_profile(profile: ProfileConfig) -> None:
395
395
  check_lineage(profile.lineage, "profile.lineage")
396
396
 
397
397
 
398
+ def entity_workers(orchestration: Mapping[str, object] | None) -> int:
399
+ """How many entities a run may run at once: 1 unless `strategy` is `parallel`, then `max_concurrent_entities` or `min(4, cpus)`."""
400
+ if not orchestration or orchestration.get("strategy") != "parallel":
401
+ return 1
402
+ limit = orchestration.get("max_concurrent_entities")
403
+ return limit if isinstance(limit, int) else min(4, os.cpu_count() or 1)
404
+
405
+
398
406
  def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
399
407
  """The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
400
408
  blocks = {
401
409
  "catalogs": profile.catalogs,
402
410
  "storages": profile.storages,
403
411
  "lineage": profile.lineage,
412
+ "entity_workers": entity_workers(profile.orchestration),
404
413
  }
405
414
  return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
406
415
 
floe/config/project.py CHANGED
@@ -93,6 +93,7 @@ class RootConfig(BaseModel):
93
93
  lineage: LineageConfig | None
94
94
  entities: list[EntityConfig]
95
95
  config_files: list[Path]
96
+ entity_workers: int = 1 # profile/manifest orchestration; not a config key
96
97
 
97
98
 
98
99
  @dataclass
floe/iceberg/catalog.py CHANGED
@@ -7,11 +7,16 @@ import tempfile
7
7
  import threading
8
8
  import weakref
9
9
  from collections.abc import Generator, Sequence
10
- from contextlib import contextmanager
10
+ from contextlib import contextmanager, suppress
11
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 (
16
+ NamespaceAlreadyExistsError,
17
+ NoSuchNamespaceError,
18
+ NoSuchTableError,
19
+ )
15
20
  from pyiceberg.partitioning import PartitionField, PartitionSpec
16
21
  from pyiceberg.schema import Schema
17
22
  from pyiceberg.table import Table
@@ -344,52 +349,59 @@ def ensure_table(
344
349
 
345
350
  With `add_columns`, columns new in `schema` are added as optional (D13) and returned by name; partition changes stay rejected.
346
351
  """
347
- ident = (namespace,)
348
- with run_error("rest catalog namespace_exists check failed"):
349
- namespace_exists = catalog.namespace_exists(ident)
350
- if not namespace_exists:
351
- if not create_namespace:
352
- raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
353
- with run_error("rest catalog namespace create failed"):
354
- catalog.create_namespace(ident)
355
352
  identifier = (namespace, name)
356
- with run_error("rest catalog table_exists check failed"):
357
- exists = catalog.table_exists(identifier)
358
- if exists:
359
- with run_error("rest catalog load_table failed"):
353
+ with run_error("rest catalog load_table failed"):
354
+ try:
360
355
  table = catalog.load_table(identifier)
361
- added: list[NestedField] = []
362
- if schema_evolution == "add_columns":
363
- added = additive_columns(table.schema(), schema, entity)
364
- # field ids of `schema` may differ from the table's once columns are added
365
- check_schema = table.schema()
366
- else:
367
- check_schema_matches(table.schema(), schema, entity)
368
- check_schema = schema
369
- check_partition_spec_matches(
370
- 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 = (
371
374
  None
372
375
  if partition_fields is None
373
- else build_partition_spec(check_schema, partition_fields, entity),
374
- entity,
376
+ else build_partition_spec(schema, partition_fields, entity)
375
377
  )
376
- if added:
377
- with run_error("rest catalog update_schema failed"):
378
- with table.update_schema() as update:
379
- for field in added:
380
- update.add_column(field.name, field.field_type, required=False)
381
- table.refresh()
382
- return table, [f.name for f in added]
383
- 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(),
384
396
  None
385
397
  if partition_fields is None
386
- else build_partition_spec(schema, partition_fields, entity)
398
+ else build_partition_spec(check_schema, partition_fields, entity),
399
+ entity,
387
400
  )
388
- with run_error("rest catalog create_table failed"):
389
- created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
390
- identifier,
391
- schema,
392
- location=location,
393
- partition_spec=spec if spec is not None else PartitionSpec(),
394
- )
395
- 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/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/lineage/observer.py CHANGED
@@ -110,7 +110,6 @@ def _warn(message: str) -> None:
110
110
  class OpenLineageObserver:
111
111
  """A run observer (`floe.run.events.Observer`) posting OpenLineage `RunEvent`s."""
112
112
 
113
- # ponytail: unsynchronised breaker counters; add a lock when entities run in parallel.
114
113
  def __init__(
115
114
  self, config: LineageConfig, entities: Sequence[EntityConfig], config_path: str
116
115
  ) -> None:
@@ -127,12 +126,14 @@ class OpenLineageObserver:
127
126
  self.run_uuid: str | None = None
128
127
  self.consecutive_failures = 0
129
128
  self.circuit_open = False
129
+ self._lock = threading.Lock() # entities may run in parallel threads
130
130
 
131
131
  def __call__(self, event: dict[str, Any]) -> None:
132
132
  kind, ts = event["event"], event["ts_ms"]
133
133
  job = self.events.run_job_name
134
134
  if kind == "run_started":
135
- self.consecutive_failures, self.circuit_open = 0, False
135
+ with self._lock:
136
+ self.consecutive_failures, self.circuit_open = 0, False
136
137
  self.run_uuid = str(uuid.uuid4())
137
138
  self._post("START", ts, self.run_uuid, job)
138
139
  elif kind == "entity_started":
@@ -171,19 +172,19 @@ class OpenLineageObserver:
171
172
  time.sleep(delay)
172
173
  retryable = self._attempt(data)
173
174
  if retryable is None:
174
- self.consecutive_failures = 0
175
+ with self._lock:
176
+ self.consecutive_failures = 0
175
177
  return
176
178
  if not retryable:
177
179
  break
178
180
  _warn(f"POST {self.url} failed — event dropped")
179
- self.consecutive_failures += 1
180
- failures = self.consecutive_failures
181
- if (
182
- failures
183
- >= (3 if self.config.max_failures is None else self.config.max_failures)
184
- and not self.circuit_open
185
- ):
186
- self.circuit_open = True
181
+ with self._lock:
182
+ self.consecutive_failures += 1
183
+ failures = self.consecutive_failures
184
+ limit = 3 if self.config.max_failures is None else self.config.max_failures
185
+ opening = failures >= limit and not self.circuit_open
186
+ self.circuit_open |= opening
187
+ if opening:
187
188
  _warn(f"disabled for this run after {failures} consecutive failures — check endpoint {self.url}") # fmt: skip
188
189
 
189
190
  def _attempt(self, data: bytes) -> bool | None:
@@ -251,6 +252,7 @@ def run_with_lineage(
251
252
  return run(
252
253
  path, root, run_id=run_id, entities=entities, dry_run=dry_run,
253
254
  full_refresh=full_refresh, observer=observe, config_uri=config_uri,
255
+ workers=root.entity_workers,
254
256
  ) # fmt: skip
255
257
  except FloeError:
256
258
  if lineage:
floe/manifest_run.py CHANGED
@@ -12,6 +12,7 @@ from typing import Any, TypeVar, cast, get_args
12
12
 
13
13
  from pydantic import BaseModel, ValidationError
14
14
 
15
+ from floe.config.profile import entity_workers
15
16
  from floe.config.project import (
16
17
  LineageConfig,
17
18
  ProjectMetadata,
@@ -265,6 +266,9 @@ def load_manifest(location: str | Path) -> tuple[RootConfig, list[EntityConfig],
265
266
  spec = m["spec_version"]
266
267
  entities = [_entity(e, _named_local(m)) for e in m["entities"]]
267
268
  report_base = m["report_base_uri"]
269
+ workers = entity_workers(
270
+ cast(Json, m.get("execution") or {}).get("orchestration")
271
+ )
268
272
  meta = m.get("metadata") # D20: dumped without its None fields
269
273
  metadata = None if meta is None else ProjectMetadata.model_validate(_omitted_as_none(ProjectMetadata, meta)) # fmt: skip
270
274
  except (ValueError, KeyError, TypeError, AttributeError) as e:
@@ -289,6 +293,7 @@ def load_manifest(location: str | Path) -> tuple[RootConfig, list[EntityConfig],
289
293
  lineage=_section(LineageConfig, m, "lineage"),
290
294
  entities=entities,
291
295
  config_files=[],
296
+ entity_workers=workers,
292
297
  )
293
298
  observed = [_lineage_entity(e, me) for e, me in zip(entities, m["entities"])]
294
299
  return root, observed, _replay_base(location, m)
floe/read/json.py CHANGED
@@ -15,6 +15,11 @@ from floe.config.types import ColumnConfig, SourceOptions
15
15
  from floe.errors import FloeIoError
16
16
 
17
17
  Selector = list[str | int] # a field name or an array index
18
+ JSON_CHUNK_ROWS = 1_000_000
19
+ # ponytail: a file of at most this many bytes is parsed once, its dicts held until read (about 16x its size
20
+ # in memory, so 8 MiB costs about 130 MiB); larger files parse twice (key pass, then chunks).
21
+ # Upgrade path: build the first chunk's rows while probing.
22
+ JSON_SINGLE_PASS_BYTES = 8 * 1024 * 1024
18
23
 
19
24
 
20
25
  @dataclass(frozen=True)
@@ -188,13 +193,15 @@ def read_json(
188
193
  options: SourceOptions,
189
194
  columns: Sequence[ColumnConfig],
190
195
  cast_mode: str = "strict",
191
- chunk_rows: int = 1_000_000,
196
+ chunk_rows: int = JSON_CHUNK_ROWS,
197
+ records: Sequence[tuple[str, Any]] | None = None,
192
198
  ) -> Iterator[JsonChunk]:
193
199
  """Yield the rows in chunks of at most `chunk_rows`, columns named by selector; always at least one.
194
200
 
195
201
  `options.json_mode` is `array` (default) or `ndjson`. A selector that finds nothing is null.
196
202
  Objects and arrays are an error unless `cast_mode` is `coerce`, which keeps their compact JSON.
197
203
  Scalars are stringified with Python's `str`; Polars would render `1.0` as `1`, so it is not used.
204
+ `records` are the file's already parsed `json_records`, read instead of parsing the file again.
198
205
  """
199
206
  mode = options.json_mode or "array"
200
207
  if mode not in ("array", "ndjson"):
@@ -221,7 +228,7 @@ def read_json(
221
228
  exact = [decimal_type(c.column_type) is not None for c in columns]
222
229
  rows: list[list[str | None]] = []
223
230
  done = 0
224
- for where, value in json_records(path, mode):
231
+ for where, value in json_records(path, mode) if records is None else records:
225
232
  rows.append(
226
233
  [
227
234
  _cell(value, p, n, cast_mode, where, e)
floe/run/__init__.py CHANGED
@@ -1,7 +1,8 @@
1
- """A run: entity selection, run context, sequential or parallel entities, dry run, full refresh, events (Rust: `run/mod.rs`, `run/context.rs`)."""
1
+ """A run: entity selection, run context, entities in config order or in parallel threads (`workers` > 1), dry run, full refresh, events (Rust: `run/mod.rs`, `run/context.rs`)."""
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
@@ -23,6 +24,7 @@ from floe.report.summary import (
23
24
  )
24
25
  from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
25
26
  from floe.run.events import Emitter, Observer
27
+ from floe.run.parallel import exclusive_keys, run_parallel
26
28
  from floe.storage.inputs import ResolvedInputs
27
29
  from floe.storage.paths import normalize_local_path
28
30
  from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
@@ -103,8 +105,12 @@ def run(
103
105
  full_refresh: bool = False,
104
106
  observer: Observer | None = None,
105
107
  config_uri: str | None = None,
108
+ workers: int = 1,
106
109
  ) -> RunOutcome:
107
- """Run the selected entities in config order, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust."""
110
+ """Run the selected entities, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust.
111
+
112
+ One worker runs them in config order. More run them in parallel threads (observers are called from those threads), never two on the same target; reports and summary stay in config order.
113
+ """
108
114
  clear_storage_clients()
109
115
  started, timer = _rfc3339(), time.monotonic()
110
116
  clear_catalogs()
@@ -153,20 +159,30 @@ def run(
153
159
 
154
160
  emit("run_started", config=config, report_base=report_path)
155
161
  # Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
156
- listed: dict[str, ResolvedInputs] = {}
157
- for e in selected:
158
- # D24: a full refresh may rebuild from the archive alone, so run_entity decides
159
- archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
160
- if not archive:
161
- listed[e.id] = inputs(e)
162
- reports: list[tuple[str, dict[str, Any]]] = []
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)}
163
171
  client = storage_client(report_storage)
164
- # D36: a policy abort fails only its entity; a FloeError still stops the run
165
- for e in selected:
172
+
173
+ def finish(e: EntityConfig) -> dict[str, Any]:
166
174
  report = one(e).report
167
- reports.append((id_path(e), report))
168
175
  write_entity_report(run_path, id_path(e), report, client)
169
176
  _finished(emit, e.id, report)
177
+ return report
178
+
179
+ # D36: a policy abort fails only its entity; a FloeError still stops the run
180
+ if workers > 1 and len(selected) > 1:
181
+ keys = [exclusive_keys(e, config_dir, root.storages, root.catalogs) for e in selected] # fmt: skip
182
+ done = run_parallel(selected, keys, workers, finish)
183
+ else:
184
+ done = [finish(e) for e in selected]
185
+ reports = [(id_path(e), r) for e, r in zip(selected, done, strict=True)]
170
186
  summary = build_summary(
171
187
  root.version, reports,
172
188
  run_id=run_id, started_at=started, finished_at=_rfc3339(),
floe/run/entity.py CHANGED
@@ -21,7 +21,7 @@ from floe.checks.mismatch import (
21
21
  MismatchOutcome,
22
22
  apply_mismatch,
23
23
  declared_names,
24
- json_input_columns,
24
+ json_probe,
25
25
  plan_mismatch,
26
26
  )
27
27
  from floe.checks.normalize import (
@@ -59,7 +59,7 @@ from floe.iceberg.write import find_committed
59
59
  from floe.pii import apply_pii_masking
60
60
  from floe.read.avro import read_avro
61
61
  from floe.read.csv import RAGGED_MESSAGE, read_csv
62
- from floe.read.json import read_json
62
+ from floe.read.json import JSON_CHUNK_ROWS, JSON_SINGLE_PASS_BYTES, read_json
63
63
  from floe.read.orc import read_orc
64
64
  from floe.read.parquet import read_parquet
65
65
  from floe.read.xlsx import read_xlsx
@@ -118,7 +118,7 @@ class EntityRun:
118
118
  aborted: bool
119
119
 
120
120
 
121
- def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
121
+ def _chunks(entity: EntityConfig, path: Path, names: list[str], head: list[tuple[str, Any]] | None = None) -> Chunks: # fmt: skip
122
122
  src = entity.source
123
123
  match src.format:
124
124
  case "csv" | "tsv":
@@ -139,7 +139,7 @@ def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
139
139
  for x in read_xml(path, src.options, entity.schema_.columns[: len(names)]):
140
140
  yield x.offset, x.df, None
141
141
  case "json": # #998
142
- for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode): # fmt: skip
142
+ for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode, JSON_CHUNK_ROWS, head): # fmt: skip
143
143
  yield j.offset, j.df, None
144
144
  case other:
145
145
  raise FloeRunError(
@@ -402,7 +402,8 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
402
402
  def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
403
403
  # Rust skips the raw copy under PII: the file cannot be masked
404
404
  plan.tracker.restore(keys) # the unreadable file leaves no keys behind
405
- keep = entity.pii is None and entity.sink.rejected is not None
405
+ # a file removed after listing has nothing to copy: the read error alone is reported
406
+ keep = entity.pii is None and entity.sink.rejected is not None and path.exists()
406
407
  raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
407
408
  return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
408
409
 
@@ -411,7 +412,9 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
411
412
  first, probe = [], xml_columns(path, entity.source.options)
412
413
  elif entity.source.format == "json":
413
414
  # D40: every record's top-level keys; D41: only a file without records has nothing to compare
414
- json_keys = json_input_columns(path, entity.source.options.json_mode or "array") # fmt: skip
415
+ # a file listed as small is parsed once: the read reuses the probe's records (limit 0 keeps none)
416
+ json_keys, head = json_probe(path, entity.source.options.json_mode or "array", JSON_CHUNK_ROWS if file.source_size is not None and file.source_size <= JSON_SINGLE_PASS_BYTES else 0) # fmt: skip
417
+ chunks = _chunks(entity, path, file_names, head)
415
418
  first, probe = [], declared if json_keys is None else json_keys
416
419
  else: # the first chunk's columns are the input columns the plan compares
417
420
  first = [next(chunks)]
floe/run/parallel.py ADDED
@@ -0,0 +1,71 @@
1
+ """Entities in parallel threads (#1105): the schedule keeps entities that share a resource apart and the failure rule of D36."""
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from concurrent.futures import Future, ThreadPoolExecutor, wait
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from floe.config.storage import CatalogsConfig, StoragesConfig
9
+ from floe.config.types import EntityConfig
10
+ from floe.errors import FloeError
11
+ from floe.iceberg.sink import resolve_target
12
+ from floe.run.entity import state_uri
13
+ from floe.storage.resolve import resolve_path
14
+
15
+ MERGE = "merge" # every merge entity shares this key: DuckDB memory, and Polaris conflicts on one table
16
+
17
+
18
+ def _canonical(uri: str) -> str:
19
+ """One spelling per destination: a local path resolved through symlinks, a remote URI without a trailing `/`."""
20
+ if uri.startswith("local://"):
21
+ return f"local://{Path(uri.removeprefix('local://')).resolve()}"
22
+ return uri.rstrip("/")
23
+
24
+
25
+ def exclusive_keys(e: EntityConfig, config_dir: Path, storages: StoragesConfig | None, catalogs: CatalogsConfig | None) -> frozenset[str]: # fmt: skip
26
+ """What `e` writes that no other running entity may touch: its accepted path, Iceberg table, state file, and the merge slot."""
27
+ sink = e.sink
28
+ try:
29
+ keys = {_canonical(resolve_path(storages, config_dir, sink.accepted.storage, sink.accepted.path, f"entity.name={e.name} sink.accepted.storage")[0])} # fmt: skip
30
+ if sink.accepted.format == "iceberg":
31
+ t = resolve_target(storages, catalogs, config_dir, e)
32
+ keys.add(f"iceberg://{t.catalog.name}/{t.namespace}/{t.table}")
33
+ if e.incremental_mode == "file":
34
+ keys.add(_canonical(state_uri(e, config_dir, storages)[0]))
35
+ except FloeError:
36
+ return frozenset() # `run_entity` raises the same error when it runs
37
+ if sink.write_mode.startswith("merge"):
38
+ keys.add(MERGE)
39
+ return frozenset(keys)
40
+
41
+
42
+ def run_parallel(entities: Sequence[EntityConfig], keys: Sequence[frozenset[str]], workers: int, task: Callable[[EntityConfig], dict[str, Any]]) -> list[dict[str, Any]]: # fmt: skip
43
+ """Run `task` over `entities` on `workers` threads, never two with a key in common; the reports in `entities` order.
44
+
45
+ After a failure no entity starts, the running ones finish (and release their claims), and the first error in `entities` order is raised.
46
+ """
47
+ reports: dict[int, dict[str, Any]] = {}
48
+ errors: dict[int, BaseException] = {}
49
+ pending = list(range(len(entities)))
50
+ running: dict[Future[dict[str, Any]], int] = {}
51
+ busy: set[str] = set()
52
+ with ThreadPoolExecutor(workers, thread_name_prefix="floe-entity") as pool:
53
+ while pending or running:
54
+ for i in list(pending):
55
+ if len(running) == workers:
56
+ break
57
+ if not keys[i] & busy:
58
+ pending.remove(i)
59
+ busy |= keys[i]
60
+ running[pool.submit(task, entities[i])] = i
61
+ for future in wait(running, return_when="FIRST_COMPLETED").done:
62
+ i = running.pop(future)
63
+ busy -= keys[i]
64
+ if error := future.exception():
65
+ errors[i] = error
66
+ pending.clear()
67
+ else:
68
+ reports[i] = future.result()
69
+ if errors:
70
+ raise errors[min(errors)]
71
+ return [reports[i] for i in range(len(entities))]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.7
3
+ Version: 0.8.9
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,53 +1,54 @@
1
- floe/__init__.py,sha256=BMJil7rW2sI2OGGcEPUcz_mkZpyKe77sbdjGh7kjPAo,13362
1
+ floe/__init__.py,sha256=jEhTn9_w_xbOK0QPixbRMOBbZ2MiGAz2oW4I9n3z6_s,13254
2
2
  floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
3
3
  floe/_cli.py,sha256=hwast3uMX0iUtkIpcChSIVHYkoYi4fR4jwCwQT53lXE,14101
4
- floe/_cli_output.py,sha256=VLxWQr-EPtIwklo9xr2CnjMD17U-7JrMUWdTtAP3jrY,8809
4
+ floe/_cli_output.py,sha256=6DBAR3cNW2kFW_QGkwP1PpVC8AbvOjQW1LMaGnmC3qs,9232
5
5
  floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
6
6
  floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
7
7
  floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
8
8
  floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
9
9
  floe/manifest.py,sha256=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
10
- floe/manifest_run.py,sha256=1XLInkpaCofMezMBs_5uIFoR5W7lbwCBFZeqRcnwUAw,12398
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
13
  floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
14
14
  floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
15
- floe/checks/mismatch.py,sha256=2Gz-tiWKxy-iehSjAlsGzPLtYeNIfBWvi4yQPlASVYQ,5309
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=jWackztS-0H3dTMXHALTZ7LsIOmwjriwoeY4vFgARr0,16046
24
- floe/config/project.py,sha256=zgTOhTDlbMr1chViMEZyv0drozZdumTaIoKDL0vv9sw,14141
23
+ floe/config/profile.py,sha256=4imKVdZ-Ehbj71OA3K4PrShE-gOYuicMDnTD-QHIkA0,16552
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=xjRCn6MFZCzleeM9DYkc8f6_In1qEQX_HQpDj1QSFWk,16380
30
+ floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
31
31
  floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
- floe/iceberg/sink.py,sha256=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
- floe/lineage/observer.py,sha256=dDYfWvPpFGjdqvFqQDZ2pTjj7uDfqji2-BTID9hzg90,10075
37
+ floe/lineage/observer.py,sha256=IFHjzsT65L-NkcQ8Noa_8CDVpLu4iGadRB2-K0Lj8t8,10216
38
38
  floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
39
  floe/read/avro.py,sha256=rhGYlrWGFnqt_tyLIph57qV2TH91ULjoJ5qUlpW_6jI,1419
40
40
  floe/read/csv.py,sha256=1lNLh6MGrInWi3TADERA--rOG1iOVcgGdo7JPtt5LdI,5237
41
- floe/read/json.py,sha256=dPUXNlQUmMeYId3lef8ssEQeHi0tIOoEblvMQDmNVt4,8284
41
+ floe/read/json.py,sha256=3wU24pKYIhheY7DRIjyb59NA4lQRPjRisXpsMH8cfdY,8807
42
42
  floe/read/orc.py,sha256=TWgdlLBR2NF_oVZ5zEmRKNXoTkq5QfF2BOFzKyzSjhY,1553
43
43
  floe/read/parquet.py,sha256=JwfuWaHj2TxBKJeaKignq8p0Op_pUfsOdVh7dDC3wcE,1491
44
44
  floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
45
45
  floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
46
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=0QGSqgVTCcEH_Upp7A-Df376aN-pQc4G3F2N6uVebMw,8309
49
- floe/run/entity.py,sha256=lSQYCAP-d-1O9R44wJF8kgO3U2B60d_PM37NmxLJJfs,49269
48
+ floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
49
+ floe/run/entity.py,sha256=2NxCKNVUn0v2Oat4FslZfNjWAu3YhGKnSks3NRwkexU,49764
50
50
  floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
51
+ floe/run/parallel.py,sha256=lKBW9NOpOu6KFOcEo-RQ56ukEOKHXPPoLI3o6gmlY_g,3435
51
52
  floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
53
  floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
53
54
  floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
@@ -60,8 +61,8 @@ floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
60
61
  floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
61
62
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
63
  floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
63
- floe_python-0.8.7.dist-info/METADATA,sha256=xk8WIhLuQqvT4lPVlzSISa5FRfsyhIpL3TjcfVUGGoo,442
64
- floe_python-0.8.7.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
65
- floe_python-0.8.7.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
66
- floe_python-0.8.7.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
67
- floe_python-0.8.7.dist-info/RECORD,,
64
+ floe_python-0.8.9.dist-info/METADATA,sha256=jwfiIw93AklPynvV_-ABBNgnNCZjBArFMK4APsKu9Bk,442
65
+ floe_python-0.8.9.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
+ floe_python-0.8.9.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
+ floe_python-0.8.9.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
+ floe_python-0.8.9.dist-info/RECORD,,