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 +3 -2
- floe/_cli_output.py +24 -11
- floe/checks/mismatch.py +24 -9
- floe/checks/normalize.py +3 -1
- floe/checks/rows.py +4 -0
- floe/config/profile.py +10 -1
- floe/config/project.py +1 -0
- floe/iceberg/catalog.py +33 -2
- floe/lineage/observer.py +13 -11
- floe/manifest_run.py +5 -0
- floe/read/csv.py +6 -5
- floe/read/json.py +9 -2
- floe/run/__init__.py +22 -7
- floe/run/entity.py +33 -23
- floe/run/parallel.py +71 -0
- floe/storage/prefetch.py +46 -0
- floe/storage/resolve.py +18 -2
- {floe_python-0.8.6.dist-info → floe_python-0.8.8.dist-info}/METADATA +1 -1
- {floe_python-0.8.6.dist-info → floe_python-0.8.8.dist-info}/RECORD +22 -20
- {floe_python-0.8.6.dist-info → floe_python-0.8.8.dist-info}/WHEEL +0 -0
- {floe_python-0.8.6.dist-info → floe_python-0.8.8.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.6.dist-info → floe_python-0.8.8.dist-info}/licenses/LICENSE +0 -0
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
|
|
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:
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self.
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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)
|
|
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
|
-
|
|
88
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
df
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
352
|
-
masks =
|
|
353
|
-
|
|
354
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
|
|
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
|
|
957
|
+
for f in queue:
|
|
948
958
|
emit("file_started", entity=entity.name, input=f.source_uri)
|
|
949
959
|
timer = time.monotonic()
|
|
950
|
-
local =
|
|
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))]
|
floe/storage/prefetch.py
ADDED
|
@@ -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
|
-
|
|
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,66 +1,68 @@
|
|
|
1
|
-
floe/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
16
|
-
floe/checks/normalize.py,sha256=
|
|
17
|
-
floe/checks/rows.py,sha256
|
|
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=
|
|
24
|
-
floe/config/project.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
41
|
-
floe/read/json.py,sha256=
|
|
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=
|
|
49
|
-
floe/run/entity.py,sha256=
|
|
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/
|
|
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.
|
|
63
|
-
floe_python-0.8.
|
|
64
|
-
floe_python-0.8.
|
|
65
|
-
floe_python-0.8.
|
|
66
|
-
floe_python-0.8.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|