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/__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 &nbsp;·&nbsp; '
119
+ f'<span style="color:#2e7d32">{totals.get("accepted_total", 0)} accepted</span> &nbsp;·&nbsp; '
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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from floe._cli import main
4
+
5
+ sys.exit(main())
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