floe-python 0.8.7__py3-none-any.whl → 0.8.8__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/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,12 @@ 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 NamespaceAlreadyExistsError
15
16
  from pyiceberg.partitioning import PartitionField, PartitionSpec
16
17
  from pyiceberg.schema import Schema
17
18
  from pyiceberg.table import Table
@@ -350,7 +351,10 @@ def ensure_table(
350
351
  if not namespace_exists:
351
352
  if not create_namespace:
352
353
  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
+ with (
355
+ run_error("rest catalog namespace create failed"),
356
+ suppress(NamespaceAlreadyExistsError),
357
+ ): # another entity or process created it first
354
358
  catalog.create_namespace(ident)
355
359
  identifier = (namespace, name)
356
360
  with run_error("rest catalog table_exists check failed"):
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,4 +1,4 @@
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
@@ -23,6 +23,7 @@ from floe.report.summary import (
23
23
  )
24
24
  from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
25
25
  from floe.run.events import Emitter, Observer
26
+ from floe.run.parallel import exclusive_keys, run_parallel
26
27
  from floe.storage.inputs import ResolvedInputs
27
28
  from floe.storage.paths import normalize_local_path
28
29
  from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
@@ -103,8 +104,12 @@ def run(
103
104
  full_refresh: bool = False,
104
105
  observer: Observer | None = None,
105
106
  config_uri: str | None = None,
107
+ workers: int = 1,
106
108
  ) -> 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."""
109
+ """Run the selected entities, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust.
110
+
111
+ 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.
112
+ """
108
113
  clear_storage_clients()
109
114
  started, timer = _rfc3339(), time.monotonic()
110
115
  clear_catalogs()
@@ -159,14 +164,21 @@ def run(
159
164
  archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
160
165
  if not archive:
161
166
  listed[e.id] = inputs(e)
162
- reports: list[tuple[str, dict[str, Any]]] = []
163
167
  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:
168
+
169
+ def finish(e: EntityConfig) -> dict[str, Any]:
166
170
  report = one(e).report
167
- reports.append((id_path(e), report))
168
171
  write_entity_report(run_path, id_path(e), report, client)
169
172
  _finished(emit, e.id, report)
173
+ return report
174
+
175
+ # D36: a policy abort fails only its entity; a FloeError still stops the run
176
+ if workers > 1 and len(selected) > 1:
177
+ keys = [exclusive_keys(e, config_dir, root.storages, root.catalogs) for e in selected] # fmt: skip
178
+ done = run_parallel(selected, keys, workers, finish)
179
+ else:
180
+ done = [finish(e) for e in selected]
181
+ reports = [(id_path(e), r) for e, r in zip(selected, done, strict=True)]
170
182
  summary = build_summary(
171
183
  root.version, reports,
172
184
  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(
@@ -411,7 +411,9 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
411
411
  first, probe = [], xml_columns(path, entity.source.options)
412
412
  elif entity.source.format == "json":
413
413
  # 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
414
+ # a file listed as small is parsed once: the read reuses the probe's records (limit 0 keeps none)
415
+ 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
416
+ chunks = _chunks(entity, path, file_names, head)
415
417
  first, probe = [], declared if json_keys is None else json_keys
416
418
  else: # the first chunk's columns are the input columns the plan compares
417
419
  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.8
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,18 +1,18 @@
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
18
  floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
@@ -20,34 +20,35 @@ 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=R0IBTaLe80k49SUuVMoAw0j7Ira2EDgjjk3XHSISIGg,16573
31
31
  floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
33
  floe/iceberg/sink.py,sha256=WyT6yrp5JQyfaqWES96NWh3V-EzgTD4Y-IjB84mZ9Sg,11842
34
34
  floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
35
35
  floe/iceberg/write.py,sha256=SRX5tb17zUeWwpg6uXKkVQ_muNJ2GqlQd0n-kmjWPVc,11141
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=leKCs7YOoPADLFzKzU8m-mdfmStbGz_VH9kAg12kzyo,8902
49
+ floe/run/entity.py,sha256=e-IfES1ajZtIBrX9UnWDOpFHSoxjfYRtCBp63QavCFY,49653
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.8.dist-info/METADATA,sha256=nkx-wlv0W9muP3wviq1E6TBG-fS7wPa1Szy7qjGig_E,442
65
+ floe_python-0.8.8.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
+ floe_python-0.8.8.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
+ floe_python-0.8.8.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
+ floe_python-0.8.8.dist-info/RECORD,,