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/_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)