floe-python 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/iceberg/unique.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Unique keys already in an Iceberg table, for append (Rust `seed_unique_tracker_for_append`)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from pyiceberg.table import Table
|
|
7
|
+
|
|
8
|
+
from floe.checks.unique import (
|
|
9
|
+
UniqueTracker,
|
|
10
|
+
_float_bits, # pyright: ignore[reportPrivateUsage] -- the tracker's own float key
|
|
11
|
+
)
|
|
12
|
+
from floe.iceberg.catalog import run_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def seed_from_table(
|
|
16
|
+
tracker: UniqueTracker,
|
|
17
|
+
table: Table,
|
|
18
|
+
batch: pl.DataFrame,
|
|
19
|
+
stored: Mapping[str, str],
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Seed `tracker` with the keys of `table` that `batch` also holds; `stored` maps runtime to table names.
|
|
22
|
+
|
|
23
|
+
A target key the batch lacks is never hit, so the flags are Rust's while memory follows the
|
|
24
|
+
batch: the table is streamed, key columns only. The join matches -0.0 to 0.0 and NaN to any
|
|
25
|
+
NaN; the tracker then compares float bits, so those extra keys are never hit.
|
|
26
|
+
A key with a column the table lacks (`add_columns` adds it on write) is skipped: historical
|
|
27
|
+
rows hold NULL there, which never duplicates.
|
|
28
|
+
"""
|
|
29
|
+
columns = set(table.schema().column_names)
|
|
30
|
+
for key in tracker.keys:
|
|
31
|
+
names = [stored.get(c, c) for c in key]
|
|
32
|
+
if not columns.issuperset(names):
|
|
33
|
+
continue
|
|
34
|
+
# positional names (k0, b0, ...): no stored column name can collide with a temporary
|
|
35
|
+
ids = [f"k{i}" for i in range(len(key))]
|
|
36
|
+
distinct = (
|
|
37
|
+
batch.select(pl.col(c).alias(k) for c, k in zip(key, ids, strict=True))
|
|
38
|
+
.drop_nulls()
|
|
39
|
+
.unique()
|
|
40
|
+
)
|
|
41
|
+
if not distinct.height: # all-NULL keys are never checked; nothing to look up
|
|
42
|
+
continue
|
|
43
|
+
# distinct by float bits, as the tracker keys them: `unique` on a float merges -0.0 with 0.0
|
|
44
|
+
floats = [k for c, k in zip(key, ids, strict=True) if batch.schema[c].is_float()] # fmt: skip
|
|
45
|
+
bits = {f"b{k}": pl.col(k).map_batches(_float_bits, return_dtype=pl.UInt64, is_elementwise=True) for k in floats} # fmt: skip
|
|
46
|
+
subset = [k for k in ids if k not in floats] + list(bits)
|
|
47
|
+
with run_error("iceberg unique seed scan failed"):
|
|
48
|
+
scan = pl.scan_iceberg(table).select(pl.col(n).alias(k) for n, k in zip(names, ids, strict=True)) # fmt: skip
|
|
49
|
+
# the table's dtypes can differ (unsigned widened, datetimes in us); a key the table type
|
|
50
|
+
# cannot hold casts to NULL, which never matches
|
|
51
|
+
lookup = distinct.cast(scan.collect_schema(), strict=False).lazy()
|
|
52
|
+
hits = (
|
|
53
|
+
scan.join(lookup, on=ids, how="semi")
|
|
54
|
+
.with_columns(**bits)
|
|
55
|
+
# distinct keys only: memory follows the batch, not the history
|
|
56
|
+
.unique(subset)
|
|
57
|
+
.drop(*bits)
|
|
58
|
+
.collect(engine="streaming")
|
|
59
|
+
)
|
|
60
|
+
back = pl.Schema({k: batch.schema[c] for c, k in zip(key, ids, strict=True)})
|
|
61
|
+
tracker.seed(hits.cast(back).rename(dict(zip(ids, key, strict=True))))
|
floe/iceberg/write.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Overwrite and append of an Arrow stream in one catalog commit, one snapshot (#597's `single` path)."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingTypeStubs=false
|
|
4
|
+
# ^ pyarrow ships no type stubs
|
|
5
|
+
|
|
6
|
+
import itertools
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator, Sequence
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import pyarrow as pa
|
|
15
|
+
import pyarrow.parquet as pq
|
|
16
|
+
from pyiceberg.exceptions import CommitFailedException
|
|
17
|
+
from pyiceberg.io.pyarrow import (
|
|
18
|
+
_check_pyarrow_schema_compatible, # pyright: ignore[reportPrivateUsage] -- same check `Transaction.append` makes
|
|
19
|
+
_dataframe_to_data_files, # pyright: ignore[reportPrivateUsage] -- feeds one snapshot producer from many chunks
|
|
20
|
+
)
|
|
21
|
+
from pyiceberg.manifest import DataFile, DataFileContent, ManifestEntryStatus
|
|
22
|
+
from pyiceberg.table import Table, TableProperties
|
|
23
|
+
from pyiceberg.table.snapshots import ancestors_of
|
|
24
|
+
|
|
25
|
+
from floe.errors import FloeRunError
|
|
26
|
+
from floe.iceberg.catalog import run_error
|
|
27
|
+
from floe.state import (
|
|
28
|
+
_backoff, # pyright: ignore[reportPrivateUsage] -- one jittered backoff for every CAS retry
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
WriteMode = Literal["overwrite", "append"]
|
|
32
|
+
|
|
33
|
+
RUN_ID = "floe.run_id"
|
|
34
|
+
INPUT_HASH = "floe.input_hash"
|
|
35
|
+
|
|
36
|
+
# `metrics::DEFAULT_SMALL_FILE_THRESHOLD_BYTES`
|
|
37
|
+
SMALL_FILE_THRESHOLD_BYTES = 16 * 1024 * 1024
|
|
38
|
+
_BYTES_PER_MIB = 1024 * 1024
|
|
39
|
+
_REPORTED_FILE_PATHS = 50
|
|
40
|
+
CHUNK_ROWS = 1_000_000
|
|
41
|
+
COMMIT_ATTEMPTS = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class IcebergWriteResult:
|
|
46
|
+
files_written: int
|
|
47
|
+
snapshot_id: int | None
|
|
48
|
+
metadata_version: int | None
|
|
49
|
+
file_paths: list[str]
|
|
50
|
+
total_bytes_written: int
|
|
51
|
+
avg_file_size_mb: float | None
|
|
52
|
+
small_files_count: int
|
|
53
|
+
already_committed: bool = (
|
|
54
|
+
False # a conflict showed another run committed this batch (#545)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]:
|
|
59
|
+
pending: list[pa.RecordBatch] = []
|
|
60
|
+
rows = 0
|
|
61
|
+
for batch in reader:
|
|
62
|
+
offset = 0
|
|
63
|
+
while offset < batch.num_rows:
|
|
64
|
+
part = batch.slice(offset, chunk_rows - rows)
|
|
65
|
+
offset += part.num_rows
|
|
66
|
+
pending.append(part)
|
|
67
|
+
rows += part.num_rows
|
|
68
|
+
if rows >= chunk_rows:
|
|
69
|
+
yield pa.Table.from_batches(pending, reader.schema)
|
|
70
|
+
pending, rows = [], 0
|
|
71
|
+
if rows:
|
|
72
|
+
yield pa.Table.from_batches(pending, reader.schema)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _metadata_version(location: str | None) -> int | None:
|
|
76
|
+
prefix = PurePosixPath(location or "").name.split("-", 1)[0]
|
|
77
|
+
return int(prefix) if prefix.isdigit() else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_table(
|
|
81
|
+
table: Table,
|
|
82
|
+
reader: pa.RecordBatchReader,
|
|
83
|
+
mode: WriteMode,
|
|
84
|
+
run_id: str,
|
|
85
|
+
input_hash: str,
|
|
86
|
+
small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
|
|
87
|
+
chunk_rows: int = CHUNK_ROWS,
|
|
88
|
+
unique_keys: Sequence[Sequence[str]] = (),
|
|
89
|
+
) -> IcebergWriteResult:
|
|
90
|
+
"""Data files are written chunk by chunk (partitioned tables split each chunk), then committed once.
|
|
91
|
+
|
|
92
|
+
Overwrite replaces every existing file in the same snapshot. An append of no rows commits nothing.
|
|
93
|
+
On a conflict an append checks `unique_keys` (table column names) against the files added since the base
|
|
94
|
+
snapshot and fails closed, committing nothing, when a rival already holds a key (#549).
|
|
95
|
+
"""
|
|
96
|
+
with run_error("iceberg data write failed"):
|
|
97
|
+
_check_pyarrow_schema_compatible(table.schema(), reader.schema)
|
|
98
|
+
counter = itertools.count()
|
|
99
|
+
write_uuid = uuid.uuid4()
|
|
100
|
+
data_files: list[DataFile] = [
|
|
101
|
+
data_file
|
|
102
|
+
for chunk in _chunks(reader, chunk_rows)
|
|
103
|
+
for data_file in _dataframe_to_data_files(
|
|
104
|
+
table.metadata, chunk, table.io, write_uuid, counter
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
base = (
|
|
108
|
+
table.metadata.current_snapshot_id
|
|
109
|
+
) # only commits after it can be a rival's copy of this batch
|
|
110
|
+
for attempt in range(COMMIT_ATTEMPTS):
|
|
111
|
+
if attempt: # a conflict: the data files stay, only the transaction is rebuilt on the fresh table
|
|
112
|
+
_backoff(attempt - 1)
|
|
113
|
+
with run_error("rest iceberg table refresh failed"):
|
|
114
|
+
table.refresh()
|
|
115
|
+
seen = mode == "append" and find_committed(
|
|
116
|
+
table, input_hash, after=base
|
|
117
|
+
)
|
|
118
|
+
if seen:
|
|
119
|
+
return replace(write_result(table, [], small_file_threshold_bytes), already_committed=True) # fmt: skip
|
|
120
|
+
if mode == "append":
|
|
121
|
+
_revalidate_unique(table, base, data_files, unique_keys)
|
|
122
|
+
replaced: list[DataFile] = []
|
|
123
|
+
if mode == "overwrite":
|
|
124
|
+
with run_error("iceberg overwrite scan failed"):
|
|
125
|
+
replaced = [task.file for task in table.scan().plan_files()]
|
|
126
|
+
if not (data_files or replaced):
|
|
127
|
+
break
|
|
128
|
+
try:
|
|
129
|
+
with run_error("rest iceberg commit failed"):
|
|
130
|
+
# PyIceberg retries a 409 itself (refresh, rebuild) and would skip the guard; in memory only, never sent
|
|
131
|
+
table.metadata = table.metadata.model_copy(update={"properties": {**table.properties, TableProperties.COMMIT_NUM_RETRIES: "0"}}) # fmt: skip
|
|
132
|
+
tx = table.transaction()
|
|
133
|
+
update = tx.update_snapshot(
|
|
134
|
+
snapshot_properties={RUN_ID: run_id, INPUT_HASH: input_hash}
|
|
135
|
+
)
|
|
136
|
+
with (
|
|
137
|
+
update.overwrite()
|
|
138
|
+
if mode == "overwrite"
|
|
139
|
+
else update.fast_append() as snap
|
|
140
|
+
):
|
|
141
|
+
for old in replaced:
|
|
142
|
+
snap.delete_data_file(old)
|
|
143
|
+
for data_file in data_files:
|
|
144
|
+
snap.append_data_file(data_file)
|
|
145
|
+
table = tx.commit_transaction()
|
|
146
|
+
except FloeRunError as e:
|
|
147
|
+
if not isinstance(e.__cause__, CommitFailedException):
|
|
148
|
+
raise
|
|
149
|
+
continue
|
|
150
|
+
break
|
|
151
|
+
else:
|
|
152
|
+
raise FloeRunError(f"rest iceberg commit failed: conflicted {COMMIT_ATTEMPTS} times in a row") # fmt: skip
|
|
153
|
+
return write_result(table, data_files, small_file_threshold_bytes)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def added_data_files(table: Table, since: int | None) -> list[DataFile]:
|
|
157
|
+
"""Data files added by the snapshots after `since` (a DuckDB MERGE commits two)."""
|
|
158
|
+
files: list[DataFile] = []
|
|
159
|
+
snapshot = table.current_snapshot()
|
|
160
|
+
while snapshot is not None and snapshot.snapshot_id != since:
|
|
161
|
+
files += [
|
|
162
|
+
entry.data_file
|
|
163
|
+
for manifest in snapshot.manifests(table.io)
|
|
164
|
+
if manifest.added_snapshot_id == snapshot.snapshot_id
|
|
165
|
+
for entry in manifest.fetch_manifest_entry(table.io)
|
|
166
|
+
if entry.status == ManifestEntryStatus.ADDED
|
|
167
|
+
and entry.data_file.content == DataFileContent.DATA
|
|
168
|
+
]
|
|
169
|
+
parent = snapshot.parent_snapshot_id
|
|
170
|
+
snapshot = None if parent is None else table.snapshot_by_id(parent)
|
|
171
|
+
return files
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _keys(
|
|
175
|
+
table: Table, file: DataFile, key: Sequence[str]
|
|
176
|
+
) -> Iterator[tuple[object, ...]]:
|
|
177
|
+
"""The non-NULL key tuples of a data file; a file without a key column (evolved later) holds none."""
|
|
178
|
+
with table.io.new_input(file.file_path).open() as f:
|
|
179
|
+
schema = pq.read_schema(f)
|
|
180
|
+
if not set(key) <= set(schema.names):
|
|
181
|
+
return
|
|
182
|
+
f.seek(0)
|
|
183
|
+
rows = pq.read_table(f, columns=list(key))
|
|
184
|
+
columns = [
|
|
185
|
+
# the tracker's float key: bit patterns, so NaN matches NaN and -0.0 differs from 0.0
|
|
186
|
+
col.combine_chunks().cast(pa.float64()).view(pa.uint64())
|
|
187
|
+
if pa.types.is_floating(col.type)
|
|
188
|
+
else col
|
|
189
|
+
for col in (rows[c] for c in key)
|
|
190
|
+
]
|
|
191
|
+
yield from (k for k in zip(*(c.to_pylist() for c in columns)) if None not in k)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _revalidate_unique(
|
|
195
|
+
table: Table,
|
|
196
|
+
base: int | None,
|
|
197
|
+
data_files: list[DataFile],
|
|
198
|
+
unique_keys: Sequence[Sequence[str]],
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Raise when a key of `data_files` is in a file a rival added since `base`.
|
|
201
|
+
|
|
202
|
+
ponytail: the rivals' keys sit in memory (cost follows the concurrent writes, not the history);
|
|
203
|
+
row-level deletes are not applied, so a deleted rival row still fails closed.
|
|
204
|
+
"""
|
|
205
|
+
with run_error("iceberg unique revalidation failed"):
|
|
206
|
+
live = {task.file.file_path for task in table.scan().plan_files()}
|
|
207
|
+
rival = [f for f in added_data_files(table, base) if f.file_path in live]
|
|
208
|
+
clash = next(
|
|
209
|
+
(
|
|
210
|
+
key
|
|
211
|
+
for key in unique_keys
|
|
212
|
+
if (held := {k for file in rival for k in _keys(table, file, key)})
|
|
213
|
+
and any(
|
|
214
|
+
k in held for file in data_files for k in _keys(table, file, key)
|
|
215
|
+
)
|
|
216
|
+
),
|
|
217
|
+
None,
|
|
218
|
+
)
|
|
219
|
+
if clash:
|
|
220
|
+
raise FloeRunError(f"unique key ({', '.join(clash)}) already committed by a concurrent run; nothing was committed, rerun to revalidate") # fmt: skip
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_result(
|
|
224
|
+
table: Table,
|
|
225
|
+
data_files: list[DataFile],
|
|
226
|
+
small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
|
|
227
|
+
) -> IcebergWriteResult:
|
|
228
|
+
"""Rust's report fields for the files a commit added."""
|
|
229
|
+
sizes = [f.file_size_in_bytes for f in data_files]
|
|
230
|
+
snapshot = table.current_snapshot()
|
|
231
|
+
return IcebergWriteResult(
|
|
232
|
+
files_written=len(data_files),
|
|
233
|
+
snapshot_id=None if snapshot is None else snapshot.snapshot_id,
|
|
234
|
+
metadata_version=_metadata_version(table.metadata_location),
|
|
235
|
+
file_paths=[PurePosixPath(f.file_path).name for f in data_files][
|
|
236
|
+
:_REPORTED_FILE_PATHS
|
|
237
|
+
],
|
|
238
|
+
total_bytes_written=sum(sizes),
|
|
239
|
+
avg_file_size_mb=sum(sizes) / len(sizes) / _BYTES_PER_MIB if sizes else None,
|
|
240
|
+
small_files_count=sum(s < small_file_threshold_bytes for s in sizes),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def find_committed(
|
|
245
|
+
table: Table,
|
|
246
|
+
input_hash: str,
|
|
247
|
+
since: datetime | None = None,
|
|
248
|
+
after: int | None = None,
|
|
249
|
+
) -> bool | None:
|
|
250
|
+
"""Whether a snapshot ancestor carries `floe.input_hash` (#545); the metadata is in memory, no manifest I/O.
|
|
251
|
+
|
|
252
|
+
The walk stops at snapshots older than `since`, the claim's `acquired_at`: a batch can't commit before its
|
|
253
|
+
claim. None when the history back to `since` was expired, so the answer is unknown. `after` is a snapshot id
|
|
254
|
+
that ends the walk: only the commits made since it count.
|
|
255
|
+
"""
|
|
256
|
+
stop = None if since is None else since.timestamp() * 1000
|
|
257
|
+
oldest = None
|
|
258
|
+
for snapshot in ancestors_of(table.current_snapshot(), table.metadata):
|
|
259
|
+
if snapshot.snapshot_id == after or (
|
|
260
|
+
stop is not None and snapshot.timestamp_ms < stop
|
|
261
|
+
):
|
|
262
|
+
return False
|
|
263
|
+
if (
|
|
264
|
+
snapshot.summary is not None
|
|
265
|
+
and snapshot.summary.get(INPUT_HASH) == input_hash
|
|
266
|
+
):
|
|
267
|
+
return True
|
|
268
|
+
oldest = snapshot
|
|
269
|
+
expired = oldest is not None and oldest.parent_snapshot_id is not None
|
|
270
|
+
return None if stop is not None and expired else False
|
floe/lineage/events.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""OpenLineage `RunEvent` bodies, built as Rust builds them (Rust: `lineage/mod.rs`)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from floe import __version__
|
|
10
|
+
from floe.config.project import LineageConfig
|
|
11
|
+
from floe.config.types import EntityConfig
|
|
12
|
+
|
|
13
|
+
DEFAULT_PRODUCER = f"https://github.com/OpenLakeForge/floe/releases/tag/v{__version__}"
|
|
14
|
+
RUN_EVENT_SCHEMA = "https://openlineage.io/spec/1-0-5/OpenLineage.json#/$defs/RunEvent"
|
|
15
|
+
DQ_SCHEMA = "https://openlineage.io/spec/facets/1-0-2/DataQualityMetricsOutputDatasetFacet.json" # fmt: skip
|
|
16
|
+
CLOUD_PREFIXES = ("s3://", "gs://", "gcs://", "az://", "abfss://", "abfs://")
|
|
17
|
+
|
|
18
|
+
Datasets = list[dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def split_storage_uri(uri: str) -> tuple[str, str]:
|
|
22
|
+
"""(namespace, name): a cloud URI splits at its authority; anything else is a `file` path."""
|
|
23
|
+
for prefix in CLOUD_PREFIXES:
|
|
24
|
+
if uri.startswith(prefix):
|
|
25
|
+
slash = uri.find("/", len(prefix))
|
|
26
|
+
return (uri, "") if slash < 0 else (uri[:slash], uri[slash + 1 :])
|
|
27
|
+
return "file", uri
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_api_key(configured: str | None, ambient: str | None) -> str | None:
|
|
31
|
+
"""An explicit key wins; else `OPENLINEAGE_API_KEY`. An unexpanded `{{...}}` counts as absent."""
|
|
32
|
+
key = (configured or "").strip()
|
|
33
|
+
if key and not (key.startswith("{{") and key.endswith("}}")):
|
|
34
|
+
return key
|
|
35
|
+
return (ambient or "").strip() or None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def event_type(status: str) -> str:
|
|
39
|
+
return "FAIL" if status in ("failed", "aborted") else "COMPLETE"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iso(ms: int) -> str:
|
|
43
|
+
"""RFC 3339 in UTC with trailing fractional zeros trimmed, as Rust's `time` crate prints it."""
|
|
44
|
+
base = datetime.fromtimestamp(ms // 1000, UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
|
45
|
+
frac = f"{ms % 1000:03d}".rstrip("0")
|
|
46
|
+
return f"{base}.{frac}Z" if frac else f"{base}Z"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dataset(namespace: str, name: str) -> dict[str, Any]:
|
|
50
|
+
return {"namespace": namespace, "name": name}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EventBuilder:
|
|
54
|
+
"""Job identity and per-entity datasets, fixed when the run starts."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self, config: LineageConfig, entities: Sequence[EntityConfig], config_path: str
|
|
58
|
+
) -> None:
|
|
59
|
+
self.namespace = config.namespace
|
|
60
|
+
self.producer = config.producer or DEFAULT_PRODUCER
|
|
61
|
+
self.run_job_name = config.job_name or Path(config_path).stem or "floe-run"
|
|
62
|
+
ds = config.dataset_namespace
|
|
63
|
+
dataset_ns = ds if ds and ds.strip() else config.namespace
|
|
64
|
+
self.entities = {e.id: e for e in entities}
|
|
65
|
+
self.uris: dict[str, tuple[dict[str, Any], ...]] = {}
|
|
66
|
+
for e in entities:
|
|
67
|
+
iceberg = e.sink.accepted.iceberg
|
|
68
|
+
if iceberg:
|
|
69
|
+
ns = e.name if iceberg.namespace is None else iceberg.namespace
|
|
70
|
+
table = e.name if iceberg.table is None else iceberg.table
|
|
71
|
+
accepted = _dataset(dataset_ns, f"{ns}.{table}")
|
|
72
|
+
else:
|
|
73
|
+
accepted = _dataset(*split_storage_uri(e.sink.accepted.path))
|
|
74
|
+
rejected = e.sink.rejected
|
|
75
|
+
self.uris[e.id] = (
|
|
76
|
+
_dataset(*split_storage_uri(e.source.path)),
|
|
77
|
+
accepted,
|
|
78
|
+
*([_dataset(*split_storage_uri(rejected.path))] if rejected else []),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def body(
|
|
82
|
+
self,
|
|
83
|
+
event_type: str,
|
|
84
|
+
ts_ms: int,
|
|
85
|
+
run_id: str,
|
|
86
|
+
job: str,
|
|
87
|
+
inputs: Datasets | None = None,
|
|
88
|
+
outputs: Datasets | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
parent = self.parent()
|
|
91
|
+
return {
|
|
92
|
+
"eventType": event_type,
|
|
93
|
+
"eventTime": iso(ts_ms),
|
|
94
|
+
"run": {"runId": run_id, "facets": {"parent": parent} if parent else {}},
|
|
95
|
+
"job": {"namespace": self.namespace, "name": job, "facets": {}},
|
|
96
|
+
"inputs": inputs or [],
|
|
97
|
+
"outputs": outputs or [],
|
|
98
|
+
"producer": self.producer,
|
|
99
|
+
"schemaURL": RUN_EVENT_SCHEMA,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def _facet(self, schema: str, **fields: Any) -> dict[str, Any]:
|
|
103
|
+
return fields | {"_producer": self.producer, "_schemaURL": schema}
|
|
104
|
+
|
|
105
|
+
def datasets(self, name: str, s: dict[str, Any]) -> tuple[Datasets, Datasets]:
|
|
106
|
+
"""Inputs and outputs of an `entity_finished` event; none for an unknown entity."""
|
|
107
|
+
if name not in self.uris:
|
|
108
|
+
return [], []
|
|
109
|
+
source, accepted, *rejected = self.uris[name]
|
|
110
|
+
columns = self.entities[name].schema_.columns
|
|
111
|
+
facets: dict[str, Any] = {
|
|
112
|
+
"schema": self._facet(
|
|
113
|
+
"https://openlineage.io/spec/facets/1-1-1/SchemaDatasetFacet.json",
|
|
114
|
+
fields=[{"name": c.name, "type": c.column_type} for c in columns],
|
|
115
|
+
),
|
|
116
|
+
"dataQualityMetrics": self._facet(
|
|
117
|
+
DQ_SCHEMA,
|
|
118
|
+
rowCount=s["accepted"],
|
|
119
|
+
validCount=s["accepted"],
|
|
120
|
+
invalidCount=0,
|
|
121
|
+
),
|
|
122
|
+
"floeQualityRun": self._facet(
|
|
123
|
+
"https://github.com/OpenLakeForge/floe/schemas/FloeQualityRunFacet.json",
|
|
124
|
+
entity=name,
|
|
125
|
+
rejectionRate=s["rejected"] / s["rows"] if s["rows"] > 0 else 0.0,
|
|
126
|
+
**{k: s[k] for k in ("files", "rows", "warnings", "errors")},
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
if columns:
|
|
130
|
+
facets["columnLineage"] = self._facet(
|
|
131
|
+
"https://openlineage.io/spec/facets/1-1-1/ColumnLineageDatasetFacet.json",
|
|
132
|
+
fields={
|
|
133
|
+
c.name: {"inputFields": [source | {"field": c.source or c.name}]}
|
|
134
|
+
for c in columns
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
outputs = [accepted | {"facets": facets}]
|
|
138
|
+
for r in rejected:
|
|
139
|
+
dq = self._facet(
|
|
140
|
+
DQ_SCHEMA,
|
|
141
|
+
rowCount=s["rejected"],
|
|
142
|
+
validCount=0,
|
|
143
|
+
invalidCount=s["rejected"],
|
|
144
|
+
)
|
|
145
|
+
outputs.append(r | {"facets": {"dataQualityMetrics": dq}})
|
|
146
|
+
return [source], outputs
|
|
147
|
+
|
|
148
|
+
def parent(self) -> dict[str, Any] | None:
|
|
149
|
+
"""The Airflow, else Dagster, parent run read from the environment."""
|
|
150
|
+
env = os.environ
|
|
151
|
+
if (run_id := env.get("AIRFLOW_CTX_DAG_RUN_ID")) is not None:
|
|
152
|
+
dag = env.get("AIRFLOW_CTX_DAG_ID", "")
|
|
153
|
+
task = env.get("AIRFLOW_CTX_TASK_ID", "")
|
|
154
|
+
job = f"{dag}.{task}" if task else dag
|
|
155
|
+
elif (run_id := env.get("DAGSTER_RUN_ID")) is not None:
|
|
156
|
+
job = env.get("DAGSTER_JOB_NAME", "")
|
|
157
|
+
else:
|
|
158
|
+
return None
|
|
159
|
+
return self._facet(
|
|
160
|
+
"https://openlineage.io/spec/facets/1-0-0/ParentRunFacet.json",
|
|
161
|
+
run={"runId": run_id},
|
|
162
|
+
job={"namespace": self.namespace, "name": job},
|
|
163
|
+
)
|