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/__init__.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The public `floe` API, matching `crates/floe-python/python/floe/_floe.pyi` (Rust: `floe-python/src/`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
6
|
+
from html import escape
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
__version__ = version(
|
|
12
|
+
"floe-python"
|
|
13
|
+
) # before the imports below: `floe.report.summary` reads it
|
|
14
|
+
|
|
15
|
+
from floe import manifest as _manifest
|
|
16
|
+
from floe import run as _run
|
|
17
|
+
from floe.config.parse import parse_config
|
|
18
|
+
from floe.config.profile import ProfileConfig, apply_profile, parse_profile
|
|
19
|
+
from floe.config.project import RootConfig as _Root
|
|
20
|
+
from floe.config.validate import validate_config
|
|
21
|
+
from floe.config.vars import extract_config_env_vars as _env_vars
|
|
22
|
+
from floe.config.vars import resolve_vars
|
|
23
|
+
from floe.errors import (
|
|
24
|
+
FloeConfigError,
|
|
25
|
+
FloeError,
|
|
26
|
+
FloeIoError,
|
|
27
|
+
FloeRunError,
|
|
28
|
+
FloeStorageError,
|
|
29
|
+
)
|
|
30
|
+
from floe.lineage.observer import run_with_lineage
|
|
31
|
+
from floe.maintain import FLOOR_DAYS, FLOOR_SNAPSHOTS, ORPHAN_DELAY_DAYS
|
|
32
|
+
from floe.maintain import maintain as _maintain
|
|
33
|
+
from floe.manifest_run import load_manifest, shown
|
|
34
|
+
from floe.report.summary import id_path
|
|
35
|
+
from floe.run.entity import state_uri
|
|
36
|
+
from floe.run.events import (
|
|
37
|
+
EntityFinishedEvent,
|
|
38
|
+
EntityStartedEvent,
|
|
39
|
+
FileFinishedEvent,
|
|
40
|
+
FileStartedEvent,
|
|
41
|
+
LogEvent,
|
|
42
|
+
RunEvent,
|
|
43
|
+
RunFinishedEvent,
|
|
44
|
+
RunStartedEvent,
|
|
45
|
+
SchemaEvolutionAppliedEvent,
|
|
46
|
+
)
|
|
47
|
+
from floe.state import dump_state, read_state, reset
|
|
48
|
+
from floe.storage.paths import normalize_local_path
|
|
49
|
+
from floe.storage.types import StorageClient
|
|
50
|
+
|
|
51
|
+
_observer: Callable[[RunEvent], None] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _debug(value: str) -> str:
|
|
55
|
+
return json.dumps(value, ensure_ascii=False) # Rust `{:?}` quoting
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, repr=False)
|
|
59
|
+
class EntityConfig:
|
|
60
|
+
name: str
|
|
61
|
+
domain: str | None
|
|
62
|
+
source_format: str
|
|
63
|
+
source_path: str
|
|
64
|
+
source_storage: str | None
|
|
65
|
+
accepted_format: str
|
|
66
|
+
accepted_path: str
|
|
67
|
+
accepted_storage: str | None
|
|
68
|
+
incremental_mode: str
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"EntityConfig(name={_debug(self.name)}, source_format={_debug(self.source_format)}, accepted_format={_debug(self.accepted_format)})" # fmt: skip
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, repr=False)
|
|
75
|
+
class RootConfig:
|
|
76
|
+
version: str
|
|
77
|
+
entities: list[EntityConfig]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def entity_names(self) -> list[str]:
|
|
81
|
+
return [e.name for e in self.entities]
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return f"RootConfig(version={_debug(self.version)}, entities={len(self.entities)})" # fmt: skip
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, repr=False)
|
|
88
|
+
class RunOutcome:
|
|
89
|
+
run_id: str
|
|
90
|
+
report_base_path: str | None
|
|
91
|
+
dry_run: bool
|
|
92
|
+
summary: dict[str, Any]
|
|
93
|
+
entity_reports: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
|
|
94
|
+
dry_run_previews: list[dict[str, Any]] | None = None
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
return asdict(self)
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
return f"RunOutcome(run_id={_debug(self.run_id)}, entities={len(self.entity_reports)}, dry_run={str(self.dry_run).lower()})" # fmt: skip
|
|
101
|
+
|
|
102
|
+
def _repr_html_(self) -> str:
|
|
103
|
+
colors = {"success": "#2e7d32", "success_with_warnings": "#e65100"}
|
|
104
|
+
status = self.summary.get("run", {}).get("status", "unknown")
|
|
105
|
+
totals = self.summary.get("results", {})
|
|
106
|
+
rows = "".join(
|
|
107
|
+
f'<tr><td style="padding:4px 8px">{escape(e["name"])}</td>'
|
|
108
|
+
f'<td style="padding:4px 8px;color:{colors.get(e["status"], "#c62828")};font-weight:bold">{e["status"]}</td>'
|
|
109
|
+
f'<td style="padding:4px 8px;text-align:right">{e["results"]["accepted_total"]}</td>'
|
|
110
|
+
f'<td style="padding:4px 8px;text-align:right">{e["results"]["rejected_total"]}</td></tr>'
|
|
111
|
+
for e in self.summary.get("entities", [])
|
|
112
|
+
) # fmt: skip
|
|
113
|
+
return (
|
|
114
|
+
'<div style="font-family:monospace;border:1px solid #ddd;border-radius:4px;padding:12px;max-width:600px">'
|
|
115
|
+
'<div style="margin-bottom:8px"><strong>RunOutcome</strong> '
|
|
116
|
+
f'<code style="color:#555">{escape(self.run_id)}</code> '
|
|
117
|
+
f'<span style="color:{colors.get(status, "#c62828")};font-weight:bold;margin-left:8px">{status}</span></div>'
|
|
118
|
+
f'<div style="margin-bottom:8px;color:#555">{totals.get("rows_total", 0)} rows · '
|
|
119
|
+
f'<span style="color:#2e7d32">{totals.get("accepted_total", 0)} accepted</span> · '
|
|
120
|
+
f'<span style="color:#c62828">{totals.get("rejected_total", 0)} rejected</span></div>'
|
|
121
|
+
'<table style="border-collapse:collapse;width:100%"><thead><tr style="border-bottom:1px solid #ddd">'
|
|
122
|
+
'<th style="padding:4px 8px;text-align:left">Entity</th><th style="padding:4px 8px;text-align:left">Status</th>'
|
|
123
|
+
'<th style="padding:4px 8px;text-align:right">Accepted</th><th style="padding:4px 8px;text-align:right">Rejected</th>'
|
|
124
|
+
f"</tr></thead><tbody>{rows}</tbody></table></div>"
|
|
125
|
+
) # fmt: skip
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _profile(
|
|
129
|
+
path: str | None, variables: dict[str, str] | None
|
|
130
|
+
) -> ProfileConfig | None:
|
|
131
|
+
"""Rust `load_optional_profile`: `profile_vars` extend the profile's, or stand alone."""
|
|
132
|
+
if path is None:
|
|
133
|
+
return None if variables is None else ProfileConfig("", {}, variables, None, None, None) # fmt: skip
|
|
134
|
+
try:
|
|
135
|
+
profile = parse_profile(Path(path))
|
|
136
|
+
except FloeError as e:
|
|
137
|
+
raise FloeConfigError(f"failed to load profile {_debug(path)}: {e}") from e
|
|
138
|
+
return ProfileConfig(profile.name, profile.runner, profile.variables | (variables or {}),
|
|
139
|
+
profile.catalogs, profile.storages, profile.lineage) # fmt: skip
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load(config_path: str, profile: ProfileConfig | None = None) -> tuple[Path, _Root]:
|
|
143
|
+
path = Path(config_path).absolute()
|
|
144
|
+
root = parse_config(path, profile.variables if profile else None)
|
|
145
|
+
return path, apply_profile(root, profile) if profile else root
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def validate(
|
|
149
|
+
config_path: str,
|
|
150
|
+
entities: list[str] | None = None,
|
|
151
|
+
profile_vars: dict[str, str] | None = None,
|
|
152
|
+
profile_path: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
_, root = _load(config_path, _profile(profile_path, profile_vars))
|
|
155
|
+
validate_config(root)
|
|
156
|
+
_run.select_entities(root, entities or [])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run(
|
|
160
|
+
config_path: str,
|
|
161
|
+
entities: list[str] | None = None,
|
|
162
|
+
dry_run: bool = False,
|
|
163
|
+
run_id: str | None = None,
|
|
164
|
+
full_refresh: bool = False,
|
|
165
|
+
profile_vars: dict[str, str] | None = None,
|
|
166
|
+
profile_path: str | None = None,
|
|
167
|
+
) -> RunOutcome:
|
|
168
|
+
profile = _profile(profile_path, profile_vars)
|
|
169
|
+
if profile: # Rust `run_with_runtime`: profile vars resolved against the config's env vars first
|
|
170
|
+
profile = replace(profile, variables=resolve_vars(profile.variables, {}, _env_vars(Path(config_path).absolute()))) # fmt: skip
|
|
171
|
+
path, root = _load(config_path, profile)
|
|
172
|
+
# as `floe run -c`: OpenLineage events, and validation after the observer so lineage sees a failure
|
|
173
|
+
out = run_with_lineage(
|
|
174
|
+
path, root, root.entities, str(path), _emit, validate=True, run_id=run_id,
|
|
175
|
+
entities=entities or [], dry_run=dry_run, full_refresh=full_refresh,
|
|
176
|
+
) # fmt: skip
|
|
177
|
+
return _outcome(root, entities, out)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def maintain(
|
|
181
|
+
config_path: str,
|
|
182
|
+
entities: list[str] | None = None,
|
|
183
|
+
older_than_days: int = FLOOR_DAYS,
|
|
184
|
+
retain_last: int = FLOOR_SNAPSHOTS,
|
|
185
|
+
profile_vars: dict[str, str] | None = None,
|
|
186
|
+
profile_path: str | None = None,
|
|
187
|
+
orphan_delay_days: int = ORPHAN_DELAY_DAYS,
|
|
188
|
+
dry_run: bool = False,
|
|
189
|
+
compact: bool = False,
|
|
190
|
+
experimental: bool = False,
|
|
191
|
+
) -> list[dict[str, Any]]:
|
|
192
|
+
"""`floe maintain`: expire Iceberg snapshots behind the retention floor (7 days, 10 snapshots), then remove orphan files."""
|
|
193
|
+
path, root = _load(config_path, _profile(profile_path, profile_vars))
|
|
194
|
+
return _maintain(path, root, entities or [], older_than_days, retain_last, orphan_delay_days, dry_run, compact, experimental) # fmt: skip
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_manifest(
|
|
198
|
+
manifest_path: str,
|
|
199
|
+
entities: list[str] | None = None,
|
|
200
|
+
dry_run: bool = False,
|
|
201
|
+
run_id: str | None = None,
|
|
202
|
+
full_refresh: bool = False,
|
|
203
|
+
) -> RunOutcome:
|
|
204
|
+
"""`floe run --manifest`, OpenLineage included; the manifest was validated when it was generated."""
|
|
205
|
+
root, observed, path = load_manifest(manifest_path)
|
|
206
|
+
out = run_with_lineage(
|
|
207
|
+
path, root, observed, "", _emit, validate=False, run_id=run_id,
|
|
208
|
+
entities=entities or [], dry_run=dry_run, full_refresh=full_refresh,
|
|
209
|
+
config_uri=shown(manifest_path),
|
|
210
|
+
) # fmt: skip
|
|
211
|
+
return _outcome(root, entities, out)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_manifest(
|
|
215
|
+
config_path: str,
|
|
216
|
+
profile_path: str | None = None,
|
|
217
|
+
*,
|
|
218
|
+
deterministic: bool = True,
|
|
219
|
+
manifest_name: str | None = None,
|
|
220
|
+
default_domain: str | None = None,
|
|
221
|
+
path_mode: str = "default",
|
|
222
|
+
runtime: str | None = None,
|
|
223
|
+
manifest_uri: str | None = None,
|
|
224
|
+
entities: list[str] | None = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""What `floe manifest generate` writes; `manifest_uri` is a remote `-o` (where it is deployed)."""
|
|
227
|
+
from floe._cli import _load as _cli_load # pyright: ignore[reportPrivateUsage] -- the CLI's config loading, so both build the same manifest
|
|
228
|
+
from floe._cli import _realpath # pyright: ignore[reportPrivateUsage] -- as above
|
|
229
|
+
|
|
230
|
+
path = _realpath(config_path)
|
|
231
|
+
profile = parse_profile(_realpath(profile_path)) if profile_path else None
|
|
232
|
+
root = _cli_load(path, profile)
|
|
233
|
+
validate_config(root)
|
|
234
|
+
options = _manifest.Options(
|
|
235
|
+
config_path, deterministic, manifest_name, default_domain, path_mode,
|
|
236
|
+
runtime, manifest_uri, profile_path, profile,
|
|
237
|
+
) # fmt: skip
|
|
238
|
+
return _manifest.build_manifest(root, path, options, entities or [])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _emit(event: dict[str, Any]) -> None:
|
|
242
|
+
if _observer:
|
|
243
|
+
_observer(cast(RunEvent, event))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _outcome(
|
|
247
|
+
root: _Root, entities: list[str] | None, out: _run.RunOutcome
|
|
248
|
+
) -> RunOutcome:
|
|
249
|
+
base = out.summary["report"]["path"]
|
|
250
|
+
if out.dry_run is None:
|
|
251
|
+
return RunOutcome(out.run_id, base, False, out.summary, out.entity_reports)
|
|
252
|
+
previews = [
|
|
253
|
+
{
|
|
254
|
+
"name": e.id, "input_path": e.source.path, "input_format": e.source.format,
|
|
255
|
+
"accepted_path": e.sink.accepted.path, "accepted_format": e.sink.accepted.format,
|
|
256
|
+
"rejected_path": e.sink.rejected and e.sink.rejected.path,
|
|
257
|
+
"rejected_format": e.sink.rejected and e.sink.rejected.format,
|
|
258
|
+
"archive_path": e.sink.archive.path if e.sink.archive else "",
|
|
259
|
+
"archive_storage": e.sink.archive and e.sink.archive.storage,
|
|
260
|
+
"report_file": f"{out.run_dir}/{id_path(e)}/run.json",
|
|
261
|
+
"scanned_files": out.dry_run[e.id],
|
|
262
|
+
}
|
|
263
|
+
for e in _run.select_entities(root, entities or [])
|
|
264
|
+
] # fmt: skip
|
|
265
|
+
return RunOutcome(out.run_id, base, True, out.summary, [], previews)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def load_config(config_path: str) -> RootConfig:
|
|
269
|
+
_, root = _load(config_path)
|
|
270
|
+
return RootConfig(root.version, [
|
|
271
|
+
EntityConfig(e.name, e.domain, e.source.format, e.source.path, e.source.storage,
|
|
272
|
+
e.sink.accepted.format, e.sink.accepted.path, e.sink.accepted.storage,
|
|
273
|
+
e.incremental_mode)
|
|
274
|
+
for e in root.entities
|
|
275
|
+
]) # fmt: skip
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def extract_config_env_vars(config_path: str) -> dict[str, str]:
|
|
279
|
+
return _env_vars(Path(config_path))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _state(config_path: str, entity_name: str) -> tuple[str, str, str, StorageClient]:
|
|
283
|
+
path, root = _load(config_path)
|
|
284
|
+
[entity] = _run.select_entities(root, [entity_name])
|
|
285
|
+
uri, client = state_uri(entity, normalize_local_path(path.parent), root.storages)
|
|
286
|
+
return entity.name, entity.incremental_mode, uri, client
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def inspect_entity_state(config_path: str, entity_name: str) -> dict[str, Any]:
|
|
290
|
+
name, mode, uri, client = _state(config_path, entity_name)
|
|
291
|
+
state = read_state(client, uri, name)
|
|
292
|
+
return {
|
|
293
|
+
"entity_name": name,
|
|
294
|
+
"incremental_mode": mode,
|
|
295
|
+
"path_uri": uri,
|
|
296
|
+
"path_local": uri.removeprefix("local://")
|
|
297
|
+
if uri.startswith("local://")
|
|
298
|
+
else None,
|
|
299
|
+
"state": json.loads(dump_state(state)) if state else None,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def reset_entity_state(config_path: str, entity_name: str) -> bool:
|
|
304
|
+
name, _, uri, client = _state(config_path, entity_name)
|
|
305
|
+
return reset(client, uri, name)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def set_observer(callback: Callable[[RunEvent], None]) -> bool:
|
|
309
|
+
global _observer
|
|
310
|
+
if not callable(callback):
|
|
311
|
+
raise TypeError("observer callback must be callable")
|
|
312
|
+
_observer = callback
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def clear_observer() -> None:
|
|
317
|
+
global _observer
|
|
318
|
+
_observer = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = [
|
|
322
|
+
"EntityConfig", "EntityFinishedEvent", "EntityStartedEvent", "FileFinishedEvent", "FileStartedEvent",
|
|
323
|
+
"FloeConfigError", "FloeError", "FloeIoError", "FloeRunError", "FloeStorageError", "LogEvent", "RootConfig",
|
|
324
|
+
"RunEvent", "RunFinishedEvent", "RunOutcome", "RunStartedEvent", "SchemaEvolutionAppliedEvent", "__version__",
|
|
325
|
+
"build_manifest", "clear_observer", "extract_config_env_vars", "inspect_entity_state", "load_config", "maintain", "reset_entity_state", "run",
|
|
326
|
+
"run_manifest", "set_observer", "validate",
|
|
327
|
+
] # fmt: skip
|
floe/__main__.py
ADDED
floe/_cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from floe import __version__, build_manifest
|
|
9
|
+
from floe._cli_output import Console, run_output
|
|
10
|
+
from floe.add_entity import add_entity
|
|
11
|
+
from floe.config.parse import parse_config
|
|
12
|
+
from floe.config.profile import (
|
|
13
|
+
API_VERSION,
|
|
14
|
+
KIND,
|
|
15
|
+
ProfileConfig,
|
|
16
|
+
apply_profile,
|
|
17
|
+
parse_profile,
|
|
18
|
+
)
|
|
19
|
+
from floe.config.project import RootConfig
|
|
20
|
+
from floe.config.validate import validate_config
|
|
21
|
+
from floe.config.vars import extract_config_env_vars, resolve_vars
|
|
22
|
+
from floe.errors import FloeConfigError, FloeError, FloeIoError
|
|
23
|
+
from floe.lineage.observer import run_with_lineage
|
|
24
|
+
from floe.maintain import FLOOR_DAYS, FLOOR_SNAPSHOTS, ORPHAN_DELAY_DAYS, maintain
|
|
25
|
+
from floe.manifest_run import load_manifest, shown
|
|
26
|
+
from floe.run import select_entities
|
|
27
|
+
from floe.run.entity import state_uri
|
|
28
|
+
from floe.state import dump_state, read_state, reset
|
|
29
|
+
from floe.storage.paths import normalize_local_path, parse_s3_uri
|
|
30
|
+
from floe.storage.s3 import S3Client
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _realpath(path: str) -> Path:
|
|
34
|
+
try:
|
|
35
|
+
return Path(os.path.realpath(path, strict=True))
|
|
36
|
+
except OSError as e:
|
|
37
|
+
raise FloeIoError(f"{e.strerror} (os error {e.errno})") from e
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
|
|
41
|
+
variables = (
|
|
42
|
+
resolve_vars(profile.variables, {}, extract_config_env_vars(path))
|
|
43
|
+
if profile
|
|
44
|
+
else {}
|
|
45
|
+
)
|
|
46
|
+
root = parse_config(path, variables)
|
|
47
|
+
if profile:
|
|
48
|
+
root = apply_profile(root, profile)
|
|
49
|
+
return root
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
53
|
+
profile = parse_profile(_realpath(args.profile)) if args.profile else None
|
|
54
|
+
if args.config is None:
|
|
55
|
+
if profile is None:
|
|
56
|
+
raise FloeConfigError(
|
|
57
|
+
"floe validate requires --config unless --profile is provided"
|
|
58
|
+
)
|
|
59
|
+
print(f"Profile valid: {profile.name}")
|
|
60
|
+
print(f"Schema: {API_VERSION}/{KIND}")
|
|
61
|
+
print(f"Catalogs: {len(profile.catalogs.definitions) if profile.catalogs else 0}") # fmt: skip
|
|
62
|
+
return 0
|
|
63
|
+
path = _realpath(args.config)
|
|
64
|
+
root = _load(path, profile)
|
|
65
|
+
validate_config(root)
|
|
66
|
+
select_entities(root, args.entities)
|
|
67
|
+
print(f"Config valid: {path}")
|
|
68
|
+
print(f"Version: {root.version}")
|
|
69
|
+
print(f"Report: {root.report.path}")
|
|
70
|
+
print(f"Entities: {len(root.entities)}")
|
|
71
|
+
for e in root.entities:
|
|
72
|
+
print(f"Entity: {e.name}")
|
|
73
|
+
print(f" Source: {e.source.format} ({e.source.path})")
|
|
74
|
+
print(f" Sink accepted: {e.sink.accepted.format} ({e.sink.accepted.path})")
|
|
75
|
+
if e.sink.rejected:
|
|
76
|
+
print(f" Sink rejected: {e.sink.rejected.format} ({e.sink.rejected.path})") # fmt: skip
|
|
77
|
+
print(f" Severity: {e.policy.severity}")
|
|
78
|
+
profile_arg = f" -p {os.path.realpath(args.profile)}" if args.profile else ""
|
|
79
|
+
print(f"Next: floe run -c {path}{profile_arg}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run(args: argparse.Namespace) -> int:
|
|
84
|
+
run_id = args.run_id or datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S.%fZ")
|
|
85
|
+
console = Console(args.log_format, run_id)
|
|
86
|
+
if args.manifest is not None: # Rust ignores `-p` here: the manifest is compiled
|
|
87
|
+
try: # Rust logs a manifest that fails to load after a `run_started`
|
|
88
|
+
root, observed, path = load_manifest(args.manifest)
|
|
89
|
+
except FloeIoError as e: # Rust: a bare io error, logged with code `error`
|
|
90
|
+
console.failed(FloeError(str(e)), args.manifest)
|
|
91
|
+
raise
|
|
92
|
+
except FloeError as e:
|
|
93
|
+
console.failed(e, args.manifest)
|
|
94
|
+
raise
|
|
95
|
+
config_uri = shown(args.manifest) # as typed, as Rust
|
|
96
|
+
job = "" # Rust names the lineage job `floe-run` (or `job_name`)
|
|
97
|
+
elif args.config is None:
|
|
98
|
+
raise FloeConfigError("one of --config or --manifest is required")
|
|
99
|
+
else:
|
|
100
|
+
profile = parse_profile(_realpath(args.profile)) if args.profile else None
|
|
101
|
+
try: # Rust logs a missing config without `run_started`
|
|
102
|
+
path = _realpath(args.config)
|
|
103
|
+
except FloeError as e:
|
|
104
|
+
console.failed(e, None)
|
|
105
|
+
raise
|
|
106
|
+
try:
|
|
107
|
+
root = _load(path, profile)
|
|
108
|
+
except FloeError as e:
|
|
109
|
+
console.failed(e, str(path))
|
|
110
|
+
raise
|
|
111
|
+
observed, job, config_uri = root.entities, str(path), None
|
|
112
|
+
try:
|
|
113
|
+
outcome = run_with_lineage(
|
|
114
|
+
path, root, observed, job, console, validate=args.manifest is None,
|
|
115
|
+
run_id=run_id, entities=args.entities, dry_run=args.dry_run, full_refresh=args.full_refresh,
|
|
116
|
+
config_uri=config_uri,
|
|
117
|
+
) # fmt: skip
|
|
118
|
+
except FloeError as e:
|
|
119
|
+
console.failed(e, config_uri or str(path))
|
|
120
|
+
raise
|
|
121
|
+
mode = "quiet" if args.quiet else "verbose" if args.verbose else "default"
|
|
122
|
+
out = run_output(outcome, root.entities, mode, console.timings)
|
|
123
|
+
print(out, file=sys.stderr if args.log_format == "json" else sys.stdout)
|
|
124
|
+
return 0 if args.dry_run else outcome.summary["run"]["exit_code"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _manifest(args: argparse.Namespace) -> int:
|
|
128
|
+
remote = "://" in args.output
|
|
129
|
+
if remote and not args.output.startswith("s3://"): # D1: gcs and adls are dropped
|
|
130
|
+
raise FloeConfigError(f"remote manifest output is not supported yet: {args.output}") # fmt: skip
|
|
131
|
+
manifest = build_manifest(
|
|
132
|
+
args.config, args.profile, deterministic=args.deterministic,
|
|
133
|
+
manifest_name=args.manifest_name, default_domain=args.default_domain,
|
|
134
|
+
path_mode=args.manifest_path_mode, runtime=args.runtime,
|
|
135
|
+
manifest_uri=args.output if remote else None, # a remote `-o` is where the manifest is deployed
|
|
136
|
+
entities=args.entities,
|
|
137
|
+
) # fmt: skip
|
|
138
|
+
text = json.dumps(manifest, indent=2, ensure_ascii=False)
|
|
139
|
+
if args.output == "-":
|
|
140
|
+
print(text)
|
|
141
|
+
return 0
|
|
142
|
+
if (
|
|
143
|
+
remote
|
|
144
|
+
): # one PutObject: readers see the old or the new manifest, never a partial one
|
|
145
|
+
S3Client(parse_s3_uri(args.output)[0]).write(args.output, text.encode())
|
|
146
|
+
else:
|
|
147
|
+
out = Path(args.output)
|
|
148
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
tmp = out.with_name(f".{out.name}.tmp")
|
|
150
|
+
tmp.write_text(text)
|
|
151
|
+
tmp.replace(out)
|
|
152
|
+
print(f"Manifest written: {args.output}")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _add_entity(args: argparse.Namespace) -> int:
|
|
157
|
+
config = (
|
|
158
|
+
args.config if not os.path.exists(args.config) else str(_realpath(args.config))
|
|
159
|
+
)
|
|
160
|
+
added = add_entity(
|
|
161
|
+
config, args.input, args.format, args.name, args.domain,
|
|
162
|
+
args.source_ref, args.resource, args.output, args.dry_run,
|
|
163
|
+
) # fmt: skip
|
|
164
|
+
if added.rendered is not None:
|
|
165
|
+
print(f"# add-entity dry run (no file written)\n# entity: {added.name}")
|
|
166
|
+
for note in added.notes:
|
|
167
|
+
print(f"# note: {note}")
|
|
168
|
+
print(added.rendered)
|
|
169
|
+
else:
|
|
170
|
+
print(f"Entity added: {added.name} (format={added.format}, columns={added.columns})") # fmt: skip
|
|
171
|
+
print(f"Config written: {added.output}")
|
|
172
|
+
for note in added.notes:
|
|
173
|
+
print(f"Note: {note}")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _state(args: argparse.Namespace) -> int:
|
|
178
|
+
if args.state_command == "reset" and not args.yes:
|
|
179
|
+
raise FloeConfigError("state reset is destructive, rerun with --yes to confirm")
|
|
180
|
+
profile = parse_profile(_realpath(args.profile)) if args.profile else None
|
|
181
|
+
path = _realpath(args.config)
|
|
182
|
+
root = _load(path, profile)
|
|
183
|
+
[entity] = select_entities(root, [args.entity])
|
|
184
|
+
uri, client = state_uri(entity, normalize_local_path(path.parent), root.storages)
|
|
185
|
+
if args.state_command == "reset":
|
|
186
|
+
removed = reset(client, uri, entity.name)
|
|
187
|
+
print(f"Entity: {entity.name}\nState path: {uri}")
|
|
188
|
+
print(f"State reset: {'removed state object' if removed else 'no state object found'}") # fmt: skip
|
|
189
|
+
return 0
|
|
190
|
+
state = read_state(client, uri, entity.name)
|
|
191
|
+
print(f"Entity: {entity.name}\nIncremental mode: {entity.incremental_mode}\nState path: {uri}") # fmt: skip
|
|
192
|
+
if state is None:
|
|
193
|
+
print("State exists: no\nTracked files: 0\nActive claims: 0")
|
|
194
|
+
return 0
|
|
195
|
+
print(f"State exists: yes\nTracked files: {len(state.files)}\nActive claims: {len(state.claims)}") # fmt: skip
|
|
196
|
+
print(f"Updated at: {state.updated_at or '(unknown)'}\n")
|
|
197
|
+
print(dump_state(state).decode())
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _maintain(args: argparse.Namespace) -> int:
|
|
202
|
+
profile = parse_profile(_realpath(args.profile)) if args.profile else None
|
|
203
|
+
path = _realpath(args.config)
|
|
204
|
+
results = maintain(
|
|
205
|
+
path, _load(path, profile), args.entities, args.older_than_days, args.retain_last,
|
|
206
|
+
args.orphan_delay_days, args.dry_run, args.compact, args.experimental,
|
|
207
|
+
) # fmt: skip
|
|
208
|
+
expired, removed = ("to expire", "to remove") if args.dry_run else ("expired", "removed") # fmt: skip
|
|
209
|
+
for r in results:
|
|
210
|
+
print(f"Entity: {r['entity']}\n Table: {r['table']}")
|
|
211
|
+
if c := r["compaction"]:
|
|
212
|
+
print(f" Compaction: {c['rewritten_data_files']} data files and {c['removed_delete_files']} delete files rewritten into {c['added_data_files']} data file(s)") # fmt: skip
|
|
213
|
+
print(f" Snapshots {expired}: {r['expired_snapshots']}, kept: {r['kept_snapshots']}") # fmt: skip
|
|
214
|
+
print(f" Orphan files {removed}: {len(r['orphan_files'])} ({r['orphan_bytes']} bytes)") # fmt: skip
|
|
215
|
+
for uri in r["orphan_files"] if args.dry_run else ():
|
|
216
|
+
print(f" {uri}")
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parser() -> argparse.ArgumentParser:
|
|
221
|
+
parser = argparse.ArgumentParser(prog="floe")
|
|
222
|
+
parser.add_argument("--version", action="version", version=f"floe {__version__}")
|
|
223
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
validate = commands.add_parser("validate")
|
|
225
|
+
run = commands.add_parser("run")
|
|
226
|
+
run_source = run.add_mutually_exclusive_group()
|
|
227
|
+
run_source.add_argument("-c", "--config")
|
|
228
|
+
run_source.add_argument("--manifest")
|
|
229
|
+
run.add_argument("--run-id")
|
|
230
|
+
run.add_argument("-F", "--full-refresh", action="store_true")
|
|
231
|
+
run.add_argument("--dry-run", action="store_true")
|
|
232
|
+
run.add_argument("--log-format", choices=("off", "text", "json"), default="off")
|
|
233
|
+
verbosity = run.add_mutually_exclusive_group()
|
|
234
|
+
verbosity.add_argument("--quiet", action="store_true")
|
|
235
|
+
verbosity.add_argument("--verbose", action="store_true")
|
|
236
|
+
validate.add_argument("-c", "--config")
|
|
237
|
+
for command in (validate, run):
|
|
238
|
+
command.add_argument("--entities", action="extend", type=lambda s: s.split(","), default=[]) # fmt: skip
|
|
239
|
+
command.add_argument("-p", "--profile")
|
|
240
|
+
generate = (
|
|
241
|
+
commands.add_parser("manifest")
|
|
242
|
+
.add_subparsers(dest="manifest_command", required=True)
|
|
243
|
+
.add_parser("generate")
|
|
244
|
+
)
|
|
245
|
+
generate.add_argument("-c", "--config", required=True)
|
|
246
|
+
generate.add_argument("-o", "--output", required=True)
|
|
247
|
+
generate.add_argument("-p", "--profile")
|
|
248
|
+
generate.add_argument("--deterministic", action="store_true")
|
|
249
|
+
generate.add_argument("--manifest-name")
|
|
250
|
+
generate.add_argument("--default-domain")
|
|
251
|
+
generate.add_argument("--manifest-path-mode", default="default")
|
|
252
|
+
generate.add_argument("--runtime", choices=("image", "cli"))
|
|
253
|
+
generate.add_argument(
|
|
254
|
+
"--entities", action="extend", default=[], type=lambda v: v.split(",")
|
|
255
|
+
)
|
|
256
|
+
add = commands.add_parser("add-entity")
|
|
257
|
+
add.add_argument("-c", "--config", required=True)
|
|
258
|
+
add.add_argument("--input", required=True)
|
|
259
|
+
add.add_argument("--format", choices=("csv", "json", "parquet"))
|
|
260
|
+
for flag in ("--name", "--domain", "--resource", "--output"):
|
|
261
|
+
add.add_argument(flag)
|
|
262
|
+
add.add_argument("--source", dest="source_ref")
|
|
263
|
+
add.add_argument("--dry-run", action="store_true")
|
|
264
|
+
state = commands.add_parser("state").add_subparsers(dest="state_command", required=True) # fmt: skip
|
|
265
|
+
for name in ("inspect", "reset"):
|
|
266
|
+
command = state.add_parser(name)
|
|
267
|
+
command.add_argument("-c", "--config", required=True)
|
|
268
|
+
command.add_argument("--entity", required=True)
|
|
269
|
+
command.add_argument("-p", "--profile")
|
|
270
|
+
if name == "reset":
|
|
271
|
+
command.add_argument("--yes", action="store_true")
|
|
272
|
+
maintain = commands.add_parser("maintain")
|
|
273
|
+
maintain.add_argument("-c", "--config", required=True)
|
|
274
|
+
maintain.add_argument("-p", "--profile")
|
|
275
|
+
maintain.add_argument("--entities", action="extend", type=lambda s: s.split(","), default=[]) # fmt: skip
|
|
276
|
+
maintain.add_argument("--older-than-days", type=int, default=FLOOR_DAYS)
|
|
277
|
+
maintain.add_argument("--retain-last", type=int, default=FLOOR_SNAPSHOTS)
|
|
278
|
+
maintain.add_argument("--orphan-delay-days", type=int, default=ORPHAN_DELAY_DAYS)
|
|
279
|
+
maintain.add_argument("--dry-run", action="store_true")
|
|
280
|
+
maintain.add_argument("--compact", action="store_true")
|
|
281
|
+
maintain.add_argument("--experimental", action="store_true")
|
|
282
|
+
return parser
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main() -> int:
|
|
286
|
+
args, extra = _parser().parse_known_args()
|
|
287
|
+
if extra: # clap's wording for an unknown flag
|
|
288
|
+
print(f"error: unexpected argument '{extra[0]}' found\n\nFor more information, try '--help'.", file=sys.stderr) # fmt: skip
|
|
289
|
+
return 2
|
|
290
|
+
try:
|
|
291
|
+
if args.command == "manifest":
|
|
292
|
+
return _manifest(args)
|
|
293
|
+
if args.command == "add-entity":
|
|
294
|
+
return _add_entity(args)
|
|
295
|
+
if args.command == "state":
|
|
296
|
+
return _state(args)
|
|
297
|
+
if args.command == "maintain":
|
|
298
|
+
return _maintain(args)
|
|
299
|
+
return _run(args) if args.command == "run" else _validate(args)
|
|
300
|
+
except FloeError as e:
|
|
301
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
302
|
+
return 1
|