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/_cli_output.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""`floe run` output: run events as log lines and the run summary (Rust: `crates/floe-cli/src/logging.rs`, `output.rs`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from floe.config.types import EntityConfig
|
|
9
|
+
from floe.errors import (
|
|
10
|
+
FloeConfigError,
|
|
11
|
+
FloeError,
|
|
12
|
+
FloeIoError,
|
|
13
|
+
FloeRunError,
|
|
14
|
+
FloeStorageError,
|
|
15
|
+
)
|
|
16
|
+
from floe.report.summary import id_path
|
|
17
|
+
from floe.run import RunOutcome
|
|
18
|
+
|
|
19
|
+
LOG_SCHEMA = "floe.log.v1"
|
|
20
|
+
MAX_PREVIEW_FILES = 50
|
|
21
|
+
_LEVELS = {
|
|
22
|
+
"warn": "warn",
|
|
23
|
+
"rejected": "warn",
|
|
24
|
+
"success_with_warnings": "warn",
|
|
25
|
+
"error": "error",
|
|
26
|
+
"aborted": "error",
|
|
27
|
+
"failed": "error",
|
|
28
|
+
}
|
|
29
|
+
_CODES: dict[type[FloeError], str] = {
|
|
30
|
+
FloeStorageError: "storage_error",
|
|
31
|
+
FloeIoError: "io_error",
|
|
32
|
+
FloeConfigError: "config_error",
|
|
33
|
+
FloeRunError: "run_error",
|
|
34
|
+
}
|
|
35
|
+
_COUNTS = ("files", "files_skipped", "rows", "accepted", "rejected", "warnings")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def event_json(event: dict[str, Any]) -> str:
|
|
39
|
+
kind = event["event"]
|
|
40
|
+
status: str = event["log_level"] if kind == "log" else event.get("status", "")
|
|
41
|
+
level = _LEVELS.get(status, "info")
|
|
42
|
+
doc: dict[str, Any] = {"schema": LOG_SCHEMA, "level": level, **event}
|
|
43
|
+
return json.dumps(doc, ensure_ascii=False, separators=(",", ":"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def event_text(e: dict[str, Any]) -> str:
|
|
47
|
+
kind = e["event"]
|
|
48
|
+
if kind == "log":
|
|
49
|
+
tags = "".join(f" {k}={e[k]}" for k in ("code", "entity", "input") if e[k] is not None) # fmt: skip
|
|
50
|
+
return f"log level={e['log_level']}{tags} {e['message']}"
|
|
51
|
+
if kind == "run_started":
|
|
52
|
+
return f"run_started run_id={e['run_id']} config={e['config']} report_base={e['report_base'] or 'disabled'}" # fmt: skip
|
|
53
|
+
if kind == "entity_started":
|
|
54
|
+
return f"entity_started name={e['name']}"
|
|
55
|
+
if kind == "file_started":
|
|
56
|
+
return f"file_started entity={e['entity']} input={e['input']}"
|
|
57
|
+
if kind == "file_finished":
|
|
58
|
+
skip = f" skip_reason={e['skip_reason']}" if e.get("skip_reason") else ""
|
|
59
|
+
return (
|
|
60
|
+
f"file_finished entity={e['entity']} input={e['input']} status={e['status']} rows={e['rows']} "
|
|
61
|
+
f"accepted={e['accepted']} rejected={e['rejected']} elapsed_ms={e['elapsed_ms']}{skip}"
|
|
62
|
+
) # fmt: skip
|
|
63
|
+
if kind == "entity_finished":
|
|
64
|
+
counts = "".join(f" {k}={e[k]}" for k in (*_COUNTS, "errors"))
|
|
65
|
+
return f"entity_finished name={e['name']} status={e['status']}{counts}"
|
|
66
|
+
if kind == "schema_evolution_applied":
|
|
67
|
+
return f"schema_evolution_applied entity={e['entity']} mode={e['mode']} added_columns={','.join(e['added_columns'])}" # fmt: skip
|
|
68
|
+
return f"run_finished status={e['status']} exit_code={e['exit_code']} summary={e['summary_uri'] or 'disabled'}" # fmt: skip
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Console:
|
|
72
|
+
"""The CLI's run observer: prints each event in `log_format` and keeps the file timings for the summary."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, log_format: str, run_id: str) -> None:
|
|
75
|
+
self.log_format, self.run_id = log_format, run_id
|
|
76
|
+
self.started = False
|
|
77
|
+
self.timings: list[dict[str, int]] = [] # per entity: input -> elapsed_ms
|
|
78
|
+
|
|
79
|
+
def __call__(self, event: dict[str, Any]) -> None:
|
|
80
|
+
kind = event["event"]
|
|
81
|
+
self.started |= kind == "run_started"
|
|
82
|
+
if kind == "entity_started":
|
|
83
|
+
self.timings.append({})
|
|
84
|
+
elif kind == "file_finished" and self.timings:
|
|
85
|
+
self.timings[-1][event["input"]] = event["elapsed_ms"]
|
|
86
|
+
if self.log_format != "off":
|
|
87
|
+
fmt = event_json if self.log_format == "json" else event_text
|
|
88
|
+
print(fmt(event), flush=True)
|
|
89
|
+
|
|
90
|
+
def failed(self, err: FloeError, config: str | None) -> None:
|
|
91
|
+
"""Rust's `emit_failed_run_events`, after a `run_started` for `config` when the run never started.
|
|
92
|
+
|
|
93
|
+
No `config` means it could not be resolved: Rust sees a bare `std::io::Error` there, so the code is `error`.
|
|
94
|
+
"""
|
|
95
|
+
base = {"run_id": self.run_id}
|
|
96
|
+
ts = {"ts_ms": time.time_ns() // 1_000_000}
|
|
97
|
+
if config is not None and not self.started:
|
|
98
|
+
self({"event": "run_started", **base, "config": config, "report_base": None, **ts}) # fmt: skip
|
|
99
|
+
code = next(
|
|
100
|
+
(c for t, c in _CODES.items() if isinstance(err, t) and config), "error"
|
|
101
|
+
)
|
|
102
|
+
self({"event": "log", **base, "log_level": "error", "code": code, "message": str(err), "entity": None, "input": None, **ts}) # fmt: skip
|
|
103
|
+
self({"event": "run_finished", **base, "status": "failed", "exit_code": 1, **dict.fromkeys(_COUNTS, 0),
|
|
104
|
+
"errors": 1, "summary_uri": None, **ts}) # fmt: skip
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _short(path: str | None) -> str:
|
|
108
|
+
return "-" if path is None else path.rstrip("/").rsplit("/", 1)[-1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _entity(
|
|
112
|
+
report: dict[str, Any], timings: dict[str, int], verbose: bool
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
src, sink = report["source"], report["sink"]
|
|
115
|
+
lines = [f"==> entity {report['entity']['name']} (severity={report['policy']['severity']}, format={src['format']})"] # fmt: skip
|
|
116
|
+
if verbose:
|
|
117
|
+
lines += [f" source: {src['format']} ({src['path']})", f" sink accepted: {sink['accepted']['format']} ({sink['accepted']['path']})"] # fmt: skip
|
|
118
|
+
if rejected := sink.get("rejected"):
|
|
119
|
+
lines.append(f" sink rejected: {rejected['format']} ({rejected['path']})")
|
|
120
|
+
if src.get("cast_mode"):
|
|
121
|
+
lines.append(f" cast_mode: {src['cast_mode']}")
|
|
122
|
+
if files := src["resolved_inputs"]["files"]:
|
|
123
|
+
lines += [" inputs:", *(f" {f}" for f in files)]
|
|
124
|
+
for f in report["files"]:
|
|
125
|
+
line = f" {f['status'].upper()} {_short(f['input_file'])}"
|
|
126
|
+
line += f" reason={f['skip_reason']}" if f.get("skip_reason") else ""
|
|
127
|
+
line += f" rows={f['row_count']} accepted={f['accepted_count']} rejected={f['rejected_count']}" # fmt: skip
|
|
128
|
+
ms = timings.get(f["input_file"])
|
|
129
|
+
line += "" if ms is None else f" elapsed_ms={ms}"
|
|
130
|
+
out = f["output"]
|
|
131
|
+
lines.append(f"{line} accepted_out={_short(out['accepted_path'])} rejected_out={_short(out['rejected_path'])}") # fmt: skip
|
|
132
|
+
return lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _preview(e: EntityConfig, files: list[str], report_file: str, verbose: bool) -> list[str]: # fmt: skip
|
|
136
|
+
rejected = e.sink.rejected
|
|
137
|
+
lines = [
|
|
138
|
+
f"==> entity {e.id} (format={e.source.format})",
|
|
139
|
+
f" Input: {e.source.path} ({e.source.format})",
|
|
140
|
+
f" Accepted Output: {e.sink.accepted.path} ({e.sink.accepted.format})",
|
|
141
|
+
f" Rejected Output: {rejected.path} ({rejected.format})"
|
|
142
|
+
if rejected
|
|
143
|
+
else " Rejected Output: -",
|
|
144
|
+
]
|
|
145
|
+
if e.sink.archive and e.sink.archive.path:
|
|
146
|
+
lines.append(f" Archive Path: {e.sink.archive.path} ({e.sink.archive.storage or 'default'})") # fmt: skip
|
|
147
|
+
shown = files if verbose else files[:MAX_PREVIEW_FILES]
|
|
148
|
+
lines += [f" Resolved Inputs: {len(files)}", " Resolved Files:", *(f" {f}" for f in shown)] # fmt: skip
|
|
149
|
+
if len(files) > len(shown):
|
|
150
|
+
lines.append(f" ... {len(files) - len(shown)} more")
|
|
151
|
+
return lines + ([f" report: {report_file}"] if verbose else [])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_output(
|
|
155
|
+
outcome: RunOutcome,
|
|
156
|
+
entities: list[EntityConfig],
|
|
157
|
+
mode: str,
|
|
158
|
+
timings: list[dict[str, int]],
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Rust's `format_run_output`; `mode` is `default`, `quiet` or `verbose`."""
|
|
161
|
+
run_dir = Path(outcome.run_dir)
|
|
162
|
+
head = [f"run id: {outcome.run_id}", f"report base: {outcome.report_base_path}"]
|
|
163
|
+
if outcome.dry_run is not None:
|
|
164
|
+
by_id = {e.id: e for e in entities}
|
|
165
|
+
blocks = [_preview(by_id[i], files, str(run_dir / id_path(by_id[i]) / "run.json"), mode == "verbose")
|
|
166
|
+
for i, files in outcome.dry_run.items()] # fmt: skip
|
|
167
|
+
results = dict.fromkeys(("files_total", "rows_total", "accepted_total", "rejected_total", "files_skipped"), 0) # fmt: skip
|
|
168
|
+
status, exit_code = "success", 0
|
|
169
|
+
else:
|
|
170
|
+
blocks = [_entity(r, t, mode == "verbose") for r, t in zip(outcome.entity_reports, timings, strict=True)] # fmt: skip
|
|
171
|
+
results, run = outcome.summary["results"], outcome.summary["run"]
|
|
172
|
+
status, exit_code = run["status"], run["exit_code"]
|
|
173
|
+
lines = ["DRY RUN MODE - No actual execution performed"] if outcome.dry_run is not None else [] # fmt: skip
|
|
174
|
+
if mode != "quiet":
|
|
175
|
+
lines += [*head, ""]
|
|
176
|
+
for i, block in enumerate(blocks):
|
|
177
|
+
lines += [""] * (i > 0) + block
|
|
178
|
+
lines.append("")
|
|
179
|
+
else:
|
|
180
|
+
lines += head
|
|
181
|
+
skipped = f" skipped={results['files_skipped']}" if results["files_skipped"] else ""
|
|
182
|
+
lines += [
|
|
183
|
+
f"Totals: files={results['files_total']} rows={results['rows_total']} accepted={results['accepted_total']} rejected={results['rejected_total']}{skipped}",
|
|
184
|
+
f"Overall: {status} (exit_code={exit_code})",
|
|
185
|
+
f"Run summary: {run_dir / 'run.summary.json'}",
|
|
186
|
+
] # fmt: skip
|
|
187
|
+
return "\n".join(lines)
|