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/report/entity.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Entity `run.json` (Rust: `report/{entity,build,mod}.rs`). Dicts in Rust's field order, serialised like serde."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from floe.checks.mismatch import MismatchOutcome
|
|
8
|
+
from floe.checks.normalize import resolve_strategy, source_names
|
|
9
|
+
from floe.checks.rows import RowCheck
|
|
10
|
+
from floe.checks.unique import UniqueResult
|
|
11
|
+
from floe.config.types import EntityConfig, SchemaEvolutionConfig, WriteMode
|
|
12
|
+
from floe.storage.inputs import ResolvedInputs
|
|
13
|
+
from floe.write.outputs import AcceptedWrite
|
|
14
|
+
|
|
15
|
+
MAX_RESOLVED_INPUTS = 50
|
|
16
|
+
_RULES = ("not_null", "cast_error", "unique", "schema_error")
|
|
17
|
+
# `serde_json::Map` is a BTreeMap, so the echoed options and metadata come out key-sorted.
|
|
18
|
+
_OPTION_KEYS = ("header", "separator", "encoding", "null_values", "recursive", "glob",
|
|
19
|
+
"json_mode", "row_tag", "namespace", "value_tag") # fmt: skip
|
|
20
|
+
_META_KEYS = ("data_product", "domain", "owner", "description", "tags")
|
|
21
|
+
_UNIQUE_EFFECT = {
|
|
22
|
+
"warn": ("warn", "warning"),
|
|
23
|
+
"reject": ("reject_rows", "rows_rejected"),
|
|
24
|
+
"abort": ("abort_run", "run_aborted"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def serialize(report: dict[str, Any]) -> str:
|
|
29
|
+
"""`serde_json::to_string_pretty`: two-space indent, raw UTF-8, no trailing newline."""
|
|
30
|
+
return json.dumps(report, indent=2, ensure_ascii=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def summarize_rules(
|
|
34
|
+
checks: Sequence[RowCheck], entity: EntityConfig
|
|
35
|
+
) -> list[dict[str, Any]]:
|
|
36
|
+
"""Rust's `summarize_validation_exprs`: per rule (fixed order), per column (sorted) violation counts."""
|
|
37
|
+
columns = entity.schema_.columns
|
|
38
|
+
names = source_names(columns, resolve_strategy(entity.schema_))
|
|
39
|
+
types = {n: c.column_type for n, c in zip(names, columns, strict=True)}
|
|
40
|
+
sources = {n: c.source for n, c in zip(names, columns, strict=True) if c.source}
|
|
41
|
+
rules: list[dict[str, Any]] = []
|
|
42
|
+
for rule in _RULES:
|
|
43
|
+
counts: dict[str, int] = {}
|
|
44
|
+
for c in checks:
|
|
45
|
+
if c.rule == rule or (rule == "schema_error" and c.rule not in _RULES):
|
|
46
|
+
counts[c.column] = counts.get(c.column, 0) + int(c.mask.sum())
|
|
47
|
+
cols: list[dict[str, Any]] = []
|
|
48
|
+
for name, n in sorted(counts.items()):
|
|
49
|
+
if n:
|
|
50
|
+
col: dict[str, Any] = {"column": name, "violations": n}
|
|
51
|
+
if rule == "cast_error" and name in types:
|
|
52
|
+
col["target_type"] = types[name]
|
|
53
|
+
if name in sources:
|
|
54
|
+
col["source"] = sources[name]
|
|
55
|
+
cols.append(col)
|
|
56
|
+
if cols:
|
|
57
|
+
total = sum(c["violations"] for c in cols)
|
|
58
|
+
rules.append(
|
|
59
|
+
{"rule": rule, "severity": entity.policy.severity, "violations": total, "columns": cols}
|
|
60
|
+
) # fmt: skip
|
|
61
|
+
return rules
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def file_report(
|
|
65
|
+
entity: EntityConfig,
|
|
66
|
+
input_file: str,
|
|
67
|
+
row_count: int,
|
|
68
|
+
rejected_count: int,
|
|
69
|
+
checks: Sequence[RowCheck],
|
|
70
|
+
mismatch: MismatchOutcome,
|
|
71
|
+
rejected_path: str | None = None,
|
|
72
|
+
archived_path: str | None = None,
|
|
73
|
+
errors_path: str | None = None,
|
|
74
|
+
forced_rejects: int = 0,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""One `files[]` entry. `rejected_count` is the split's; severity decides status and counts (Rust `validate_split`)."""
|
|
77
|
+
# a rejected/aborted mismatch decides the file before any row check runs (Rust `precheck.rs`)
|
|
78
|
+
precheck = mismatch.aborted or mismatch.rejected
|
|
79
|
+
rules = [] if precheck else summarize_rules(checks, entity)
|
|
80
|
+
violations = sum(r["violations"] for r in rules)
|
|
81
|
+
severity = entity.policy.severity
|
|
82
|
+
if precheck:
|
|
83
|
+
status = "aborted" if mismatch.aborted else "rejected"
|
|
84
|
+
accepted, rejected = 0, row_count
|
|
85
|
+
elif (
|
|
86
|
+
severity == "warn" or not violations
|
|
87
|
+
): # warn still rejects merge-key duplicates
|
|
88
|
+
status, accepted, rejected = (
|
|
89
|
+
"success",
|
|
90
|
+
row_count - forced_rejects,
|
|
91
|
+
forced_rejects,
|
|
92
|
+
)
|
|
93
|
+
elif severity == "reject":
|
|
94
|
+
status, accepted, rejected = (
|
|
95
|
+
"rejected",
|
|
96
|
+
row_count - rejected_count,
|
|
97
|
+
rejected_count,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
status, accepted, rejected = "aborted", 0, row_count
|
|
101
|
+
warnings = violations if severity == "warn" else 0
|
|
102
|
+
return {
|
|
103
|
+
"input_file": input_file,
|
|
104
|
+
"status": status,
|
|
105
|
+
"row_count": row_count,
|
|
106
|
+
"accepted_count": accepted,
|
|
107
|
+
"rejected_count": rejected,
|
|
108
|
+
"mismatch": mismatch.report,
|
|
109
|
+
"output": {
|
|
110
|
+
"accepted_path": None,
|
|
111
|
+
"rejected_path": rejected_path,
|
|
112
|
+
"errors_path": errors_path,
|
|
113
|
+
"archived_path": archived_path,
|
|
114
|
+
},
|
|
115
|
+
"validation": {
|
|
116
|
+
"errors": violations - warnings + mismatch.errors,
|
|
117
|
+
"warnings": warnings + mismatch.warnings,
|
|
118
|
+
"rules": rules,
|
|
119
|
+
},
|
|
120
|
+
} # fmt: skip
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _unique_report(severity: str, r: UniqueResult) -> dict[str, Any]:
|
|
124
|
+
action, effect = (
|
|
125
|
+
_UNIQUE_EFFECT[severity] if r.duplicates_count else ("none", "none")
|
|
126
|
+
)
|
|
127
|
+
out: dict[str, Any] = {"columns": r.columns, "duplicates_count": r.duplicates_count}
|
|
128
|
+
if batch := r.duplicates_count - r.target_duplicates_count:
|
|
129
|
+
out["batch_duplicates_count"] = batch
|
|
130
|
+
if r.target_duplicates_count:
|
|
131
|
+
out["target_duplicates_count"] = r.target_duplicates_count
|
|
132
|
+
out |= {
|
|
133
|
+
"affected_rows_count": r.duplicates_count,
|
|
134
|
+
"action": action,
|
|
135
|
+
"status_effect": effect,
|
|
136
|
+
}
|
|
137
|
+
if r.samples:
|
|
138
|
+
out["samples"] = [
|
|
139
|
+
{"values": dict(sorted(s.values.items())), "count": s.count}
|
|
140
|
+
for s in r.samples
|
|
141
|
+
]
|
|
142
|
+
return out # fmt: skip
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_report(
|
|
146
|
+
entity: EntityConfig,
|
|
147
|
+
version: str,
|
|
148
|
+
inputs: ResolvedInputs,
|
|
149
|
+
files: list[dict[str, Any]],
|
|
150
|
+
write: AcceptedWrite,
|
|
151
|
+
*,
|
|
152
|
+
source_uri: str,
|
|
153
|
+
accepted_uri: str,
|
|
154
|
+
rejected_uri: str | None,
|
|
155
|
+
unique: Sequence[UniqueResult] = (),
|
|
156
|
+
write_mode: WriteMode | None = None,
|
|
157
|
+
table_root_uri: str | None = None,
|
|
158
|
+
table: Mapping[str, Any] | None = None,
|
|
159
|
+
merge: Mapping[str, Any] | None = None,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""The entity's `run.json`; `files` are the `file_report`s, in input order.
|
|
162
|
+
|
|
163
|
+
`table` holds a table sink's fields (Iceberg: version, snapshot, catalog), whose metrics are reported even for no files.
|
|
164
|
+
"""
|
|
165
|
+
src, sink, severity = entity.source, entity.sink, entity.policy.severity
|
|
166
|
+
if write.files_written:
|
|
167
|
+
for f in files:
|
|
168
|
+
if f["status"] != "skipped":
|
|
169
|
+
f["output"]["accepted_path"] = accepted_uri
|
|
170
|
+
totals: dict[str, int] = {
|
|
171
|
+
"files_total": len(files),
|
|
172
|
+
"files_skipped": sum(f["status"] == "skipped" for f in files),
|
|
173
|
+
"rows_total": sum(f["row_count"] for f in files),
|
|
174
|
+
"accepted_total": sum(f["accepted_count"] for f in files),
|
|
175
|
+
"rejected_total": sum(f["rejected_count"] for f in files),
|
|
176
|
+
"warnings_total": sum(f["validation"]["warnings"] for f in files),
|
|
177
|
+
"errors_total": sum(f["validation"]["errors"] for f in files),
|
|
178
|
+
}
|
|
179
|
+
entity_echo: dict[str, Any] = {"name": entity.id}
|
|
180
|
+
if entity.metadata:
|
|
181
|
+
entity_echo["metadata"] = {
|
|
182
|
+
k: v
|
|
183
|
+
for k in sorted(_META_KEYS)
|
|
184
|
+
if (v := getattr(entity.metadata, k)) is not None
|
|
185
|
+
}
|
|
186
|
+
sink_echo: dict[str, Any] = {
|
|
187
|
+
"accepted": {"format": sink.accepted.format, "path": accepted_uri}
|
|
188
|
+
}
|
|
189
|
+
if sink.rejected:
|
|
190
|
+
sink_echo["rejected"] = {
|
|
191
|
+
"format": sink.rejected.format,
|
|
192
|
+
"path": rejected_uri or sink.rejected.path,
|
|
193
|
+
}
|
|
194
|
+
archive: dict[str, Any] = {
|
|
195
|
+
"enabled": (entity.incremental_mode in ("none", "archive"))
|
|
196
|
+
and sink.archive is not None
|
|
197
|
+
}
|
|
198
|
+
if sink.archive:
|
|
199
|
+
archive["path"] = sink.archive.path
|
|
200
|
+
sink_echo["archive"] = archive
|
|
201
|
+
evolution = entity.schema_.schema_evolution or SchemaEvolutionConfig()
|
|
202
|
+
report: dict[str, Any] = {
|
|
203
|
+
"spec_version": version,
|
|
204
|
+
"entity": entity_echo,
|
|
205
|
+
"source": {
|
|
206
|
+
"format": src.format,
|
|
207
|
+
"path": source_uri,
|
|
208
|
+
"options": {
|
|
209
|
+
k: v for k in sorted(_OPTION_KEYS) if (v := getattr(src.options, k)) is not None
|
|
210
|
+
},
|
|
211
|
+
"cast_mode": src.cast_mode,
|
|
212
|
+
"read_plan": "raw_and_typed",
|
|
213
|
+
"resolved_inputs": {
|
|
214
|
+
"mode": inputs.mode,
|
|
215
|
+
"file_count": len(inputs.listed),
|
|
216
|
+
"files": inputs.listed[:MAX_RESOLVED_INPUTS],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
"sink": sink_echo,
|
|
220
|
+
"policy": {"severity": severity},
|
|
221
|
+
"accepted_output": {
|
|
222
|
+
"path": accepted_uri,
|
|
223
|
+
"table_root_uri": table_root_uri or accepted_uri,
|
|
224
|
+
"write_mode": write_mode or sink.write_mode,
|
|
225
|
+
"accepted_rows": totals["accepted_total"],
|
|
226
|
+
"files_written": write.files_written,
|
|
227
|
+
"parts_written": write.files_written,
|
|
228
|
+
**({"part_files": write.part_files} if write.part_files else {}),
|
|
229
|
+
**(table or {}),
|
|
230
|
+
# Rust's default output (nothing written) has no byte metrics
|
|
231
|
+
"total_bytes_written": write.total_bytes_written if write.files_written or table else None,
|
|
232
|
+
"avg_file_size_mb": write.avg_file_size_mb if write.files_written or table else None,
|
|
233
|
+
"small_files_count": write.small_files_count if write.files_written or table else None,
|
|
234
|
+
**(merge or {}),
|
|
235
|
+
},
|
|
236
|
+
"schema_evolution": {
|
|
237
|
+
"enabled": False,
|
|
238
|
+
"mode": evolution.mode,
|
|
239
|
+
"applied": False,
|
|
240
|
+
"incompatible_changes_detected": False,
|
|
241
|
+
},
|
|
242
|
+
} # fmt: skip
|
|
243
|
+
if unique:
|
|
244
|
+
report["unique_constraints"] = [_unique_report(severity, r) for r in unique]
|
|
245
|
+
return report | {"results": totals, "files": files}
|
floe/report/summary.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Run `run.summary.json`, run status and exit code, report layout (Rust: `report/{mod,output}.rs`, `run/mod.rs`)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from floe import __version__
|
|
7
|
+
from floe.config.project import ProjectMetadata
|
|
8
|
+
from floe.config.types import EntityConfig
|
|
9
|
+
from floe.report.entity import serialize
|
|
10
|
+
from floe.storage.types import StorageClient
|
|
11
|
+
|
|
12
|
+
_TOTALS = ("files_total", "files_skipped", "rows_total", "accepted_total",
|
|
13
|
+
"rejected_total", "warnings_total", "errors_total") # fmt: skip
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def id_path(entity: EntityConfig) -> str:
|
|
17
|
+
"""Report sub-directory of an entity: `<domain>/<name>` when the id is qualified."""
|
|
18
|
+
if entity.domain and entity.id != entity.name:
|
|
19
|
+
return f"{entity.domain}/{entity.name}"
|
|
20
|
+
return entity.id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_domain(entities: Sequence[EntityConfig]) -> str | None:
|
|
24
|
+
"""D37: the domain every entity of the run belongs to, else None; a pre-0.3 domain that is no safe directory name (`..`, `a/b`) is None too."""
|
|
25
|
+
domains = {e.domain for e in entities}
|
|
26
|
+
domain = domains.pop() if len(domains) == 1 else None
|
|
27
|
+
return domain if domain and not any(c in domain for c in "./\\") else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_dir(report_path: str, run_id: str, domain: str | None) -> str:
|
|
31
|
+
"""D37: `<report>/<domain>/run_<id>` for a single-domain run, else `<report>/run_<id>`."""
|
|
32
|
+
return "/".join([report_path.rstrip("/"), *([domain] if domain else []), f"run_{run_id}"]) # fmt: skip
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_outcome(file_statuses: Sequence[str], warnings: int = 0) -> tuple[str, int]:
|
|
36
|
+
"""Rust's `compute_run_outcome` then `upgrade_status_for_warnings`: (status, exit code)."""
|
|
37
|
+
if "failed" in file_statuses:
|
|
38
|
+
return "failed", 1
|
|
39
|
+
if "aborted" in file_statuses:
|
|
40
|
+
return "aborted", 2
|
|
41
|
+
if "rejected" in file_statuses:
|
|
42
|
+
return "rejected", 0
|
|
43
|
+
return ("success_with_warnings" if warnings else "success"), 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_summary(
|
|
47
|
+
version: str,
|
|
48
|
+
reports: Sequence[tuple[str, dict[str, Any]]],
|
|
49
|
+
*,
|
|
50
|
+
run_id: str,
|
|
51
|
+
started_at: str,
|
|
52
|
+
finished_at: str,
|
|
53
|
+
duration_ms: int,
|
|
54
|
+
config_path: str,
|
|
55
|
+
report_path: str,
|
|
56
|
+
metadata: ProjectMetadata | None = None,
|
|
57
|
+
domain: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""`reports` are the entity `run.json` dicts in run order, each with its `id_path`; `domain` is the run's one domain (D37)."""
|
|
60
|
+
run_path = run_dir(report_path, run_id, domain)
|
|
61
|
+
totals = {k: sum(r["results"][k] for _, r in reports) for k in _TOTALS}
|
|
62
|
+
statuses = [f["status"] for _, r in reports for f in r["files"]]
|
|
63
|
+
status, exit_code = run_outcome(statuses, totals["warnings_total"])
|
|
64
|
+
entities = [
|
|
65
|
+
{
|
|
66
|
+
"name": r["entity"]["name"],
|
|
67
|
+
"status": run_outcome(
|
|
68
|
+
[f["status"] for f in r["files"]], r["results"]["warnings_total"]
|
|
69
|
+
)[0],
|
|
70
|
+
"results": r["results"],
|
|
71
|
+
"report_file": f"{run_path}/{path}/run.json",
|
|
72
|
+
}
|
|
73
|
+
for path, r in reports
|
|
74
|
+
]
|
|
75
|
+
return {
|
|
76
|
+
"spec_version": version,
|
|
77
|
+
"tool": {"name": "floe", "version": __version__},
|
|
78
|
+
"run": {
|
|
79
|
+
"run_id": run_id,
|
|
80
|
+
}
|
|
81
|
+
| ({"domain": domain} if domain else {})
|
|
82
|
+
| {
|
|
83
|
+
"started_at": started_at,
|
|
84
|
+
"finished_at": finished_at,
|
|
85
|
+
"duration_ms": duration_ms,
|
|
86
|
+
"status": status,
|
|
87
|
+
"exit_code": exit_code,
|
|
88
|
+
},
|
|
89
|
+
"config": {"path": config_path, "version": version}
|
|
90
|
+
| ({"metadata": metadata.model_dump(exclude_none=True)} if metadata else {}),
|
|
91
|
+
"report": {"path": report_path, "report_file": f"{run_path}/run.summary.json"},
|
|
92
|
+
"results": totals,
|
|
93
|
+
"entities": entities,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_reports(
|
|
98
|
+
summary: dict[str, Any],
|
|
99
|
+
reports: Sequence[tuple[str, dict[str, Any]]],
|
|
100
|
+
client: StorageClient,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Write `<run_dir>/<id_path>/run.json` per entity, then `run.summary.json` (overwriting, as Rust)."""
|
|
103
|
+
summary_file = summary["report"]["report_file"]
|
|
104
|
+
run_dir = summary_file.removesuffix("/run.summary.json")
|
|
105
|
+
for path, report in reports:
|
|
106
|
+
write_entity_report(run_dir, path, report, client)
|
|
107
|
+
client.write(summary_file, serialize(summary).encode())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_entity_report(
|
|
111
|
+
run_dir: str, path: str, report: dict[str, Any], client: StorageClient
|
|
112
|
+
) -> None:
|
|
113
|
+
"""One entity's `run.json`, written as the entity finishes (Rust)."""
|
|
114
|
+
client.write(f"{run_dir}/{path}/run.json", serialize(report).encode())
|
floe/run/__init__.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A run: entity selection, run context, sequential or parallel entities, dry run, full refresh, events (Rust: `run/mod.rs`, `run/context.rs`)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from floe.checks.rows import Formatter
|
|
11
|
+
from floe.config.project import RootConfig
|
|
12
|
+
from floe.config.types import EntityConfig
|
|
13
|
+
from floe.errors import FloeConfigError, FloeError
|
|
14
|
+
from floe.report.summary import (
|
|
15
|
+
build_summary,
|
|
16
|
+
id_path,
|
|
17
|
+
run_dir,
|
|
18
|
+
run_domain,
|
|
19
|
+
run_outcome,
|
|
20
|
+
write_entity_report,
|
|
21
|
+
write_reports,
|
|
22
|
+
)
|
|
23
|
+
from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
|
|
24
|
+
from floe.run.events import Emitter, Observer
|
|
25
|
+
from floe.storage.inputs import ResolvedInputs
|
|
26
|
+
from floe.storage.paths import normalize_local_path
|
|
27
|
+
from floe.storage.resolve import resolve_path, storage_client
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RunOutcome:
|
|
32
|
+
run_id: str
|
|
33
|
+
summary: dict[str, Any]
|
|
34
|
+
dry_run: dict[str, list[str]] | None # entity id -> scanned files
|
|
35
|
+
report_base_path: str
|
|
36
|
+
run_dir: str # D37: `<report>/<domain>/run_<id>` for a single-domain run
|
|
37
|
+
entity_reports: list[dict[str, Any]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def select_entities(root: RootConfig, selectors: Sequence[str]) -> list[EntityConfig]:
|
|
41
|
+
"""Config order; a selector is an entity id, else a name (Rust `select_entities`)."""
|
|
42
|
+
if not selectors:
|
|
43
|
+
return list(root.entities)
|
|
44
|
+
picked: set[int] = set()
|
|
45
|
+
missing: list[str] = []
|
|
46
|
+
for sel in selectors:
|
|
47
|
+
hits = [i for i, e in enumerate(root.entities) if e.id == sel] or [
|
|
48
|
+
i for i, e in enumerate(root.entities) if e.name == sel
|
|
49
|
+
]
|
|
50
|
+
if len(hits) > 1:
|
|
51
|
+
ids = ", ".join(root.entities[i].id for i in hits)
|
|
52
|
+
raise FloeConfigError(f"entity {sel} is ambiguous; use one of: {ids}")
|
|
53
|
+
if hits:
|
|
54
|
+
picked.add(hits[0])
|
|
55
|
+
else:
|
|
56
|
+
missing.append(sel)
|
|
57
|
+
if missing:
|
|
58
|
+
raise FloeConfigError(f"entities not found: {', '.join(missing)}")
|
|
59
|
+
return [e for i, e in enumerate(root.entities) if i in picked]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rfc3339() -> str:
|
|
63
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _refresh(entity: EntityConfig) -> EntityConfig:
|
|
67
|
+
"""Full refresh: an append sink overwrites; merge sinks cannot refresh."""
|
|
68
|
+
mode = entity.sink.write_mode
|
|
69
|
+
if mode in ("merge_scd1", "merge_scd2"):
|
|
70
|
+
raise FloeConfigError(f"entity '{entity.name}': --full-refresh is not supported with write_mode '{mode}'") # fmt: skip
|
|
71
|
+
if mode != "append":
|
|
72
|
+
return entity
|
|
73
|
+
return entity.model_copy(update={"sink": entity.sink.model_copy(update={"write_mode": "overwrite"})}) # fmt: skip
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _finished(emit: Emitter, name: str, report: dict[str, Any] | None) -> None:
|
|
77
|
+
"""`entity_finished`; a failed entity (no report) counts zeros."""
|
|
78
|
+
keys = ("files", "files_skipped", "rows", "accepted", "rejected", "warnings", "errors") # fmt: skip
|
|
79
|
+
results: dict[str, int] = report["results"] if report else {}
|
|
80
|
+
totals = {k: results.get(k if k == "files_skipped" else f"{k}_total", 0) for k in keys} # fmt: skip
|
|
81
|
+
status = "failed"
|
|
82
|
+
if report:
|
|
83
|
+
status = run_outcome([f["status"] for f in report["files"]], totals["warnings"])[0] # fmt: skip
|
|
84
|
+
emit("entity_finished", name=name, status=status, **totals)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run(
|
|
88
|
+
path: Path,
|
|
89
|
+
root: RootConfig,
|
|
90
|
+
*,
|
|
91
|
+
run_id: str | None = None,
|
|
92
|
+
entities: Sequence[str] = (),
|
|
93
|
+
dry_run: bool = False,
|
|
94
|
+
full_refresh: bool = False,
|
|
95
|
+
observer: Observer | None = None,
|
|
96
|
+
config_uri: str | None = None,
|
|
97
|
+
) -> RunOutcome:
|
|
98
|
+
"""Run the selected entities in config order, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust."""
|
|
99
|
+
started, timer = _rfc3339(), time.monotonic()
|
|
100
|
+
selected = select_entities(root, entities)
|
|
101
|
+
if full_refresh:
|
|
102
|
+
selected = [_refresh(e) for e in selected]
|
|
103
|
+
# a symlinked manifest or config: paths are relative to its target
|
|
104
|
+
config_dir = normalize_local_path(path.resolve().parent)
|
|
105
|
+
# a remote manifest is reported as typed
|
|
106
|
+
config = config_uri or str(normalize_local_path(path))
|
|
107
|
+
report_uri, report_storage = resolve_path(root.storages, config_dir, root.report.storage, root.report.path, "report.storage") # fmt: skip
|
|
108
|
+
report_path = report_uri.removeprefix("local://")
|
|
109
|
+
run_id = run_id or started.replace(":", "-")
|
|
110
|
+
domain = run_domain(selected)
|
|
111
|
+
run_path = run_dir(report_path, run_id, domain)
|
|
112
|
+
|
|
113
|
+
def inputs(e: EntityConfig) -> ResolvedInputs:
|
|
114
|
+
return list_source(
|
|
115
|
+
e, config_dir, root.storages, *resolve_source(e, config_dir, root.storages), full_refresh
|
|
116
|
+
) # fmt: skip
|
|
117
|
+
|
|
118
|
+
if dry_run: # Rust: no events, no reports; the inputs are only listed
|
|
119
|
+
scanned = {e.id: [f.source_uri for f in inputs(e).files] for e in selected}
|
|
120
|
+
summary = build_summary(
|
|
121
|
+
root.version, [], run_id=run_id, started_at=started, finished_at=_rfc3339(),
|
|
122
|
+
duration_ms=0, config_path=config, report_path=report_path,
|
|
123
|
+
metadata=root.metadata, domain=domain,
|
|
124
|
+
) # fmt: skip
|
|
125
|
+
summary["report"]["report_file"] = "disabled (dry-run)"
|
|
126
|
+
return RunOutcome(run_id, summary, scanned, report_path, run_path, [])
|
|
127
|
+
emit = Emitter(observer, run_id)
|
|
128
|
+
formatter = cast(
|
|
129
|
+
Formatter, root.report.formatter or "json"
|
|
130
|
+
) # validated with the config
|
|
131
|
+
|
|
132
|
+
def one(entity: EntityConfig) -> EntityRun:
|
|
133
|
+
emit("entity_started", name=entity.id)
|
|
134
|
+
try:
|
|
135
|
+
return run_entity(
|
|
136
|
+
entity, config_dir, root.version, formatter, root.storages, emit, full_refresh,
|
|
137
|
+
root.catalogs, listed.get(entity.id),
|
|
138
|
+
) # fmt: skip
|
|
139
|
+
except FloeError:
|
|
140
|
+
_finished(emit, entity.id, None)
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
emit("run_started", config=config, report_base=report_path)
|
|
144
|
+
# Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
|
|
145
|
+
listed: dict[str, ResolvedInputs] = {}
|
|
146
|
+
for e in selected:
|
|
147
|
+
# D24: a full refresh may rebuild from the archive alone, so run_entity decides
|
|
148
|
+
archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
|
|
149
|
+
if not archive:
|
|
150
|
+
listed[e.id] = inputs(e)
|
|
151
|
+
reports: list[tuple[str, dict[str, Any]]] = []
|
|
152
|
+
client = storage_client(report_storage)
|
|
153
|
+
# D36: a policy abort fails only its entity; a FloeError still stops the run
|
|
154
|
+
for e in selected:
|
|
155
|
+
report = one(e).report
|
|
156
|
+
reports.append((id_path(e), report))
|
|
157
|
+
write_entity_report(run_path, id_path(e), report, client)
|
|
158
|
+
_finished(emit, e.id, report)
|
|
159
|
+
summary = build_summary(
|
|
160
|
+
root.version, reports,
|
|
161
|
+
run_id=run_id, started_at=started, finished_at=_rfc3339(),
|
|
162
|
+
duration_ms=int((time.monotonic() - timer) * 1000),
|
|
163
|
+
config_path=config, report_path=report_path,
|
|
164
|
+
metadata=root.metadata, domain=domain,
|
|
165
|
+
) # fmt: skip
|
|
166
|
+
write_reports(summary, [], client)
|
|
167
|
+
r, t = summary["run"], summary["results"]
|
|
168
|
+
emit("run_finished", status=r["status"], exit_code=r["exit_code"], files=t["files_total"],
|
|
169
|
+
files_skipped=t["files_skipped"], rows=t["rows_total"], accepted=t["accepted_total"], rejected=t["rejected_total"],
|
|
170
|
+
warnings=t["warnings_total"], errors=t["errors_total"],
|
|
171
|
+
summary_uri=summary["report"]["report_file"], report_base=report_path,
|
|
172
|
+
entity_report_uris={e["name"]: e["report_file"] for e in summary["entities"]}) # fmt: skip
|
|
173
|
+
return RunOutcome(
|
|
174
|
+
run_id, summary, None, report_path, run_path, [r for _, r in reports]
|
|
175
|
+
)
|