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.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. 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
+ )