floe-python 0.8.6__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
@@ -46,6 +46,7 @@ from floe.run.events import (
46
46
  )
47
47
  from floe.state import dump_state, read_state, reset
48
48
  from floe.storage.paths import normalize_local_path
49
+ from floe.storage.resolve import clear_storage_clients
49
50
  from floe.storage.types import StorageClient
50
51
 
51
52
  _observer: Callable[[RunEvent], None] | None = None
@@ -135,8 +136,7 @@ def _profile(
135
136
  profile = parse_profile(path)
136
137
  except FloeError as e:
137
138
  raise FloeConfigError(f"failed to load profile {_debug(path)}: {e}") from e
138
- return ProfileConfig(profile.name, profile.runner, profile.variables | (variables or {}),
139
- profile.catalogs, profile.storages, profile.lineage) # fmt: skip
139
+ return replace(profile, variables=profile.variables | (variables or {}))
140
140
 
141
141
 
142
142
  def _load(config_path: str, profile: ProfileConfig | None = None) -> tuple[Path, _Root]:
@@ -281,6 +281,7 @@ def extract_config_env_vars(config_path: str) -> dict[str, str]:
281
281
 
282
282
 
283
283
  def _state(config_path: str, entity_name: str) -> tuple[str, str, str, StorageClient]:
284
+ clear_storage_clients()
284
285
  path, root = _load(config_path)
285
286
  [entity] = _run.select_entities(root, [entity_name])
286
287
  uri, client = state_uri(entity, normalize_local_path(path.parent), root.storages)
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/normalize.py CHANGED
@@ -115,4 +115,6 @@ def normalize_columns(df: pl.DataFrame, strategy: str | None) -> pl.DataFrame:
115
115
  f"normalized input column collision: {seen[new]} and {old} -> {new}"
116
116
  )
117
117
  seen[new] = old
118
- return df.rename(names)
118
+ renamed = df.clone()
119
+ renamed.columns = list(names.values()) # a rename is a query plan, this is not
120
+ return renamed
floe/checks/rows.py CHANGED
@@ -109,6 +109,10 @@ def split_rows(
109
109
  sources: Mapping[str, str] | None = None,
110
110
  ) -> tuple[pl.DataFrame, pl.DataFrame]:
111
111
  """`(accepted, rejected)`; rejected adds `__floe_row_index` (UInt64, row position in `df`) and `__floe_errors`."""
112
+ # a null mask passes the row, as in `error_column`
113
+ if not any(c.mask.any() for c in checks):
114
+ none = [pl.Series(ROW_INDEX, [], pl.UInt64), pl.Series(ERRORS, [], pl.String)]
115
+ return df, df.clear().with_columns(none) # replaces a clashing column, as below
112
116
  errors = error_column(df.height, checks, formatter, sources)
113
117
  rejected = df.with_columns(
114
118
  pl.int_range(pl.len(), dtype=pl.UInt64).alias(ROW_INDEX), errors
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
@@ -4,13 +4,15 @@ import os
4
4
  import re
5
5
  import shutil
6
6
  import tempfile
7
+ import threading
7
8
  import weakref
8
9
  from collections.abc import Generator, Sequence
9
- from contextlib import contextmanager
10
+ from contextlib import contextmanager, suppress
10
11
  from urllib.parse import urlsplit
11
12
 
12
13
  import duckdb
13
14
  from pyiceberg.catalog import Catalog, load_catalog
15
+ from pyiceberg.exceptions import NamespaceAlreadyExistsError
14
16
  from pyiceberg.partitioning import PartitionField, PartitionSpec
15
17
  from pyiceberg.schema import Schema
16
18
  from pyiceberg.table import Table
@@ -83,8 +85,34 @@ def _credential_props(catalog: str, credential: str) -> dict[str, str]:
83
85
  return {"credential": credential.removeprefix("client_credentials:")}
84
86
 
85
87
 
88
+ _CATALOGS: dict[tuple[CatalogDefinition, StorageDefinition | None], Catalog] = {}
89
+ _CATALOGS_LOCK = threading.Lock()
90
+
91
+
92
+ def clear_catalogs() -> None:
93
+ """Called when a run starts: a catalog is reused within a run only, never across runs (#1101)."""
94
+ with _CATALOGS_LOCK:
95
+ _CATALOGS.clear()
96
+
97
+
86
98
  def open_catalog(
87
99
  definition: CatalogDefinition, storage: StorageDefinition | None = None
100
+ ) -> Catalog:
101
+ """One catalog per definition and storage until `clear_catalogs`, so the REST init (config, OAuth token) happens once.
102
+
103
+ Safe to call from several threads (one init per key). PyIceberg 0.12 refetches an expired OAuth token
104
+ itself: its REST calls retry once on a 401 after a new client-credentials request; a static `token:` has
105
+ nothing to refetch, as with a freshly opened catalog. A failed init is not kept.
106
+ """
107
+ key = (definition, storage)
108
+ with _CATALOGS_LOCK:
109
+ if key not in _CATALOGS:
110
+ _CATALOGS[key] = _open_catalog(definition, storage)
111
+ return _CATALOGS[key]
112
+
113
+
114
+ def _open_catalog(
115
+ definition: CatalogDefinition, storage: StorageDefinition | None
88
116
  ) -> Catalog:
89
117
  """Credentials for S3 and Glue come from the environment; the catalog never vends them (as in Rust)."""
90
118
  props = {
@@ -323,7 +351,10 @@ def ensure_table(
323
351
  if not namespace_exists:
324
352
  if not create_namespace:
325
353
  raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
326
- 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
327
358
  catalog.create_namespace(ident)
328
359
  identifier = (namespace, name)
329
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/csv.py CHANGED
@@ -79,15 +79,16 @@ def _scan_args(options: SourceOptions) -> _Args:
79
79
 
80
80
  def _columns(path: Path, args: _Args, declared: Sequence[str]) -> list[str]:
81
81
  try:
82
- probe = args.scan(path, n_rows=0 if args.has_header else 1).collect()
82
+ probe = args.scan(path, n_rows=0 if args.has_header else 1)
83
+ if args.has_header: # the schema is the header: no plan to run
84
+ return probe.collect_schema().names()
85
+ width = probe.collect().width
83
86
  except (pl.exceptions.PolarsError, OSError) as err:
84
87
  # Rust's polars prints the error kind: `no data: empty CSV`
85
88
  kind = "no data: " if isinstance(err, pl.exceptions.NoDataError) else ""
86
89
  raise FloeIoError(f"csv header read failed: {kind}{err}") from err
87
- if args.has_header:
88
- return probe.columns
89
- names = list(declared[: probe.width])
90
- return names + [f"extra_column_{i + 1}" for i in range(len(declared), probe.width)]
90
+ names = list(declared[:width])
91
+ return names + [f"extra_column_{i + 1}" for i in range(len(declared), width)]
91
92
 
92
93
 
93
94
  def _field_counts(path: Path, args: _Args, skip: int) -> Iterator[int]:
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
@@ -11,6 +11,7 @@ from floe.checks.rows import Formatter
11
11
  from floe.config.project import RootConfig
12
12
  from floe.config.types import EntityConfig
13
13
  from floe.errors import FloeConfigError, FloeError
14
+ from floe.iceberg.catalog import clear_catalogs
14
15
  from floe.report.summary import (
15
16
  build_summary,
16
17
  id_path,
@@ -22,9 +23,10 @@ from floe.report.summary import (
22
23
  )
23
24
  from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
24
25
  from floe.run.events import Emitter, Observer
26
+ from floe.run.parallel import exclusive_keys, run_parallel
25
27
  from floe.storage.inputs import ResolvedInputs
26
28
  from floe.storage.paths import normalize_local_path
27
- from floe.storage.resolve import resolve_path, storage_client
29
+ from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
28
30
 
29
31
 
30
32
  @dataclass(frozen=True)
@@ -102,9 +104,15 @@ def run(
102
104
  full_refresh: bool = False,
103
105
  observer: Observer | None = None,
104
106
  config_uri: str | None = None,
107
+ workers: int = 1,
105
108
  ) -> RunOutcome:
106
- """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
+ """
113
+ clear_storage_clients()
107
114
  started, timer = _rfc3339(), time.monotonic()
115
+ clear_catalogs()
108
116
  selected = select_entities(root, entities)
109
117
  if full_refresh:
110
118
  selected = [_refresh(e) for e in selected]
@@ -156,14 +164,21 @@ def run(
156
164
  archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
157
165
  if not archive:
158
166
  listed[e.id] = inputs(e)
159
- reports: list[tuple[str, dict[str, Any]]] = []
160
167
  client = storage_client(report_storage)
161
- # D36: a policy abort fails only its entity; a FloeError still stops the run
162
- for e in selected:
168
+
169
+ def finish(e: EntityConfig) -> dict[str, Any]:
163
170
  report = one(e).report
164
- reports.append((id_path(e), report))
165
171
  write_entity_report(run_path, id_path(e), report, client)
166
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)]
167
182
  summary = build_summary(
168
183
  root.version, reports,
169
184
  run_id=run_id, started_at=started, finished_at=_rfc3339(),
floe/run/entity.py CHANGED
@@ -5,7 +5,7 @@ import tempfile
5
5
  import threading
6
6
  import time
7
7
  from collections.abc import Callable, Generator, Iterable, Iterator
8
- from contextlib import suppress
8
+ from contextlib import closing, suppress
9
9
  from dataclasses import dataclass, field, replace
10
10
  from datetime import datetime
11
11
  from functools import partial
@@ -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
@@ -85,7 +85,6 @@ from floe.storage.inputs import (
85
85
  InputFile,
86
86
  NoInputsError,
87
87
  ResolvedInputs,
88
- localize_input,
89
88
  resolve_inputs,
90
89
  suffixes_for_format,
91
90
  )
@@ -99,6 +98,7 @@ from floe.storage.partitioning import (
99
98
  window,
100
99
  )
101
100
  from floe.storage.paths import normalize_local_path
101
+ from floe.storage.prefetch import localized_inputs
102
102
  from floe.storage.resolve import resolve_path, storage_client
103
103
  from floe.storage.s3 import S3Client
104
104
  from floe.storage.types import StorageClient
@@ -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(
@@ -335,27 +335,31 @@ def _typed(
335
335
  ) -> tuple[pl.DataFrame, list[RowCheck]]:
336
336
  entity, src = plan.entity, plan.entity.source
337
337
  # D28 (#913): the derived columns join after the file's own columns are normalised and matched
338
- df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch).with_columns(
339
- derived
340
- )
341
- df = df.select(
342
- pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
343
- for n in plan.names
344
- )
338
+ df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch)
339
+ if derived:
340
+ df = df.with_columns(derived)
341
+ if df.columns != plan.names:
342
+ df = df.select(
343
+ pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
344
+ for n in plan.names
345
+ )
345
346
  columns = entity.schema_.columns
347
+ tag = "_e_cast_" # the mask columns' prefix: no declared column may start with it
348
+ while any(n.startswith(tag) for n in plan.names):
349
+ tag = f"_{tag}"
346
350
  try:
347
351
  casts = [
348
352
  cast_column(n, c.column_type, src.cast_mode, df.schema[n])
349
353
  for n, c in zip(plan.names, columns, strict=True)
350
354
  ]
351
- typed = df.with_columns(t for t, _ in casts)
352
- masks = df.with_columns(
353
- m for _, m in casts
354
- ) # unlike select, broadcasts constant masks
355
+ # one plan for both; unlike select, with_columns broadcasts the constant masks
356
+ masks = [m.alias(f"{tag}{n}") for n, (_, m) in zip(plan.names, casts, strict=True)] # fmt: skip
357
+ both = df.with_columns([t for t, _ in casts] + masks)
358
+ typed = both[plan.names]
355
359
  except pl.exceptions.InvalidOperationError as err:
356
360
  raise FloeRunError(f"entity.name={entity.name} cast failed: {err}") from err
357
361
  cast_checks = [
358
- RowCheck("cast_error", n, CAST_ERROR_MESSAGE, masks[f"_e_cast_{n}"])
362
+ RowCheck("cast_error", n, CAST_ERROR_MESSAGE, both[f"{tag}{n}"])
359
363
  for n, c in zip(plan.names, columns, strict=True)
360
364
  if c.column_type != "string"
361
365
  ] # fmt: skip
@@ -407,7 +411,9 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
407
411
  first, probe = [], xml_columns(path, entity.source.options)
408
412
  elif entity.source.format == "json":
409
413
  # D40: every record's top-level keys; D41: only a file without records has nothing to compare
410
- 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)
411
417
  first, probe = [], declared if json_keys is None else json_keys
412
418
  else: # the first chunk's columns are the input columns the plan compares
413
419
  first = [next(chunks)]
@@ -940,14 +946,18 @@ def _run_entity(
940
946
  def accepted_frames() -> Generator[pl.DataFrame]:
941
947
  """Files are read as the sink pulls (#1056); `files` and `complete` are final once it is exhausted."""
942
948
  try:
943
- # Rust `localize_input`: a remote input is read from a local copy
944
- with tempfile.TemporaryDirectory(prefix="floe-") as temp_dir:
949
+ queue = pending[::-1] if latest_first else pending
950
+ # Rust `localize_input`: a remote input is read from a local copy; #1102: downloaded ahead, and closed before the temp dir goes
951
+ with (
952
+ tempfile.TemporaryDirectory(prefix="floe-") as temp_dir,
953
+ closing(localized_inputs(queue, downloader, Path(temp_dir))) as inputs,
954
+ ):
945
955
  if pending and sink.write_mode == "append" and plan.tracker.keys and (target is None or plan.table is not None): # fmt: skip
946
956
  plan.history = _key_cache(plan, accepted_client, base, Path(temp_dir)) # fmt: skip
947
- for f in pending[::-1] if latest_first else pending:
957
+ for f in queue:
948
958
  emit("file_started", entity=entity.name, input=f.source_uri)
949
959
  timer = time.monotonic()
950
- local = localize_input(f, downloader, Path(temp_dir))
960
+ local = next(inputs)
951
961
  report, kept = _run_file(plan, f, local.local_path, Path(temp_dir))
952
962
  if local.is_ephemeral:
953
963
  local.local_path.unlink()
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))]
@@ -0,0 +1,46 @@
1
+ """Download the inputs of one entity a few files ahead of the one being read (#1102)."""
2
+
3
+ from collections import deque
4
+ from collections.abc import Generator, Iterable
5
+ from concurrent.futures import Future, ThreadPoolExecutor
6
+ from pathlib import Path
7
+
8
+ from floe.storage.inputs import InputFile, LocalInputFile, localize_input
9
+ from floe.storage.types import Downloader
10
+
11
+ # ponytail: a fixed window of 8 files and 512 MiB (unknown size counts as 0; the next file is always allowed) and one pool per entity; a config knob or a run-wide pool once a bucket needs more
12
+ PREFETCH_WINDOW = 8
13
+ PREFETCH_BYTES = 512 * 1024 * 1024
14
+
15
+
16
+ def localized_inputs(
17
+ files: Iterable[InputFile], client: Downloader | None, temp_dir: Path
18
+ ) -> Generator[LocalInputFile]:
19
+ """`localize_input` for each file, in order. Close it to stop: pending downloads are cancelled and the running ones awaited."""
20
+ if client is None: # local inputs are used in place; no pool
21
+ for f in files:
22
+ yield localize_input(f, None, temp_dir)
23
+ return
24
+ todo = deque(files)
25
+ ahead: deque[tuple[Future[LocalInputFile], int]] = deque()
26
+ pool = ThreadPoolExecutor(PREFETCH_WINDOW, thread_name_prefix="floe-prefetch")
27
+
28
+ def refill() -> None:
29
+ held = sum(size for _, size in ahead)
30
+ while todo and len(ahead) < PREFETCH_WINDOW:
31
+ size = todo[0].source_size or 0
32
+ if ahead and held + size > PREFETCH_BYTES:
33
+ break
34
+ held += size
35
+ ahead.append(
36
+ (pool.submit(localize_input, todo.popleft(), client, temp_dir), size)
37
+ )
38
+
39
+ try:
40
+ refill()
41
+ while ahead:
42
+ local = ahead.popleft()[0].result()
43
+ refill()
44
+ yield local
45
+ finally:
46
+ pool.shutdown(cancel_futures=True)
floe/storage/resolve.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Storage reference resolution: name, else `storages.default`, else local (Rust `StorageResolver::resolve_path`)."""
2
2
 
3
3
  from pathlib import Path
4
+ from threading import Lock
4
5
 
5
6
  from floe.config.storage import StorageDefinition, StoragesConfig
6
7
  from floe.errors import FloeConfigError, FloeRunError
@@ -48,8 +49,23 @@ def resolve_path(
48
49
  return f"s3://{definition.bucket}/{key}" if key else f"s3://{definition.bucket}", definition # fmt: skip
49
50
 
50
51
 
52
+ _clients: dict[StorageDefinition, S3Client] = {}
53
+ _clients_lock = Lock()
54
+
55
+
56
+ def clear_storage_clients() -> None:
57
+ """Start of a run: the clients below read credentials and `AWS_*` env when built."""
58
+ with _clients_lock:
59
+ _clients.clear()
60
+
61
+
51
62
  def storage_client(definition: StorageDefinition | None) -> LocalClient | S3Client:
52
- """S3 credentials come from the environment, as in Rust."""
63
+ """S3 credentials come from the environment, as in Rust. One shared client per definition between `clear_storage_clients` calls."""
53
64
  if definition is None or definition.type == "local":
54
65
  return LocalClient()
55
- return S3Client(definition.bucket or "", definition.region, definition.endpoint, definition.path_style_access) # fmt: skip
66
+ with (
67
+ _clients_lock
68
+ ): # boto3 clients are thread-safe; the lock only stops a duplicate build
69
+ if definition not in _clients:
70
+ _clients[definition] = S3Client(definition.bucket or "", definition.region, definition.endpoint, definition.path_style_access) # fmt: skip
71
+ return _clients[definition]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.6
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,66 +1,68 @@
1
- floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
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
16
- floe/checks/normalize.py,sha256=4g-Mw-UOtLnitavD3lPWUUYweGqGMUx_8dhq2AJU8oc,4331
17
- floe/checks/rows.py,sha256=E-YUZUvBUyOB7zat2dtbPWFM-0T1-01lYKuQU1A6wto,4156
15
+ floe/checks/mismatch.py,sha256=RNqRL1nihH6Y83RI-dxPXf0WTWZccXq6mnR4m6V0IIo,5827
16
+ floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
17
+ floe/checks/rows.py,sha256=-yPieSus0-7ZewLv2vR8waHqQ9DGMbVvmJh1_hK4eDs,4433
18
18
  floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
19
19
  floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
21
21
  floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
22
22
  floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
23
- floe/config/profile.py,sha256=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=n2NlgqRgnTe3Etdyra70Ib2aYtpR7zXWrajKl7spyYg,15340
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
- floe/read/csv.py,sha256=5nXUGBa2s2Tt790UvOcrvfDym906JYjyhgunV6xPzr4,5153
41
- floe/read/json.py,sha256=dPUXNlQUmMeYId3lef8ssEQeHi0tIOoEblvMQDmNVt4,8284
40
+ floe/read/csv.py,sha256=1lNLh6MGrInWi3TADERA--rOG1iOVcgGdo7JPtt5LdI,5237
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=Nm1OVIJF3p3DrkH3MCbBOzKcRFuFlMDSxhhdGyrM5N8,8189
49
- floe/run/entity.py,sha256=xw9RPspLM9ZrY5CmQfk5qBJXcxUvO5aaosLs5UdOiPs,48710
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
54
55
  floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
55
56
  floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
56
57
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
57
- floe/storage/resolve.py,sha256=pFAuVytTfnSEcTAVlJjFTj7S_Vipjry1ormWZP3Egmc,2593
58
+ floe/storage/prefetch.py,sha256=b-_be13xkJIw3LVndOb6SouTd1Pf8uZwG6Wy0Nk1pr8,1801
59
+ floe/storage/resolve.py,sha256=0hGSdU_p10j9vzET25Kcnd-QF6H7uN-hzpo_SC-DV_c,3154
58
60
  floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
59
61
  floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
60
62
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
63
  floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
62
- floe_python-0.8.6.dist-info/METADATA,sha256=eUy15Jwu4CdMPA1EJDxZYVGwz3gRcJN02PyqJW-5Qjo,442
63
- floe_python-0.8.6.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.6.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.6.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.6.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,,