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
@@ -0,0 +1,261 @@
1
+ """OpenLineage observer for `floe run` over plain HTTP (Rust: `lineage/mod.rs`). Lineage failures never fail the run."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import sys
7
+ import threading
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ import uuid
12
+ from collections.abc import Callable, Sequence
13
+ from contextlib import suppress
14
+ from http.client import HTTPConnection, HTTPMessage, HTTPResponse, HTTPSConnection
15
+ from pathlib import Path
16
+ from typing import IO, Any
17
+ from urllib.parse import urlsplit
18
+
19
+ from floe.config.project import LineageConfig, RootConfig
20
+ from floe.config.types import EntityConfig
21
+ from floe.config.validate import validate_config
22
+ from floe.errors import FloeError
23
+ from floe.lineage.events import Datasets, EventBuilder, event_type, resolve_api_key
24
+ from floe.run import RunOutcome, run
25
+
26
+ RETRY_DELAYS_S = (0.0, 0.1, 0.5)
27
+
28
+
29
+ def _origin(url: str) -> tuple[str, str | None, int | None]:
30
+ u = urlsplit(url)
31
+ return u.scheme, u.hostname, u.port or {"http": 80, "https": 443}.get(u.scheme)
32
+
33
+
34
+ class _Attempt:
35
+ """One POST and its redirects. On the deadline the caller cancels it: the live socket is shut down and nothing is sent afterwards."""
36
+
37
+ def __init__(self) -> None:
38
+ self.lock = threading.Lock()
39
+ self.cancelled = False
40
+ self.sock: socket.socket | None = None
41
+
42
+ def hooked(self, base: type[HTTPConnection]) -> type[HTTPConnection]:
43
+ attempt = self
44
+
45
+ class Conn(base):
46
+ def connect(self) -> None:
47
+ super().connect()
48
+ with attempt.lock:
49
+ if attempt.cancelled:
50
+ self.close()
51
+ raise OSError("lineage attempt cancelled")
52
+ attempt.sock = self.sock
53
+
54
+ return Conn
55
+
56
+ def cancel(self) -> None:
57
+ with self.lock:
58
+ self.cancelled = True
59
+ if self.sock:
60
+ with suppress(OSError):
61
+ self.sock.shutdown(socket.SHUT_RDWR)
62
+
63
+
64
+ class _Handler(urllib.request.HTTPHandler, urllib.request.HTTPSHandler):
65
+ """urllib's HTTP(S) handlers, but every connection reports its socket to the attempt."""
66
+
67
+ def __init__(self, attempt: _Attempt) -> None:
68
+ super().__init__()
69
+ self.attempt = attempt
70
+
71
+ def http_open(self, req: urllib.request.Request) -> HTTPResponse:
72
+ return self.do_open(self.attempt.hooked(HTTPConnection), req)
73
+
74
+ def https_open(self, req: urllib.request.Request) -> HTTPResponse:
75
+ return self.do_open(self.attempt.hooked(HTTPSConnection), req)
76
+
77
+
78
+ class _Redirect(urllib.request.HTTPRedirectHandler):
79
+ """Redirects as reqwest follows them: 307/308 keep the POST; `Authorization` never crosses origins."""
80
+
81
+ def __init__(self, attempt: _Attempt) -> None:
82
+ self.attempt = attempt
83
+
84
+ def redirect_request(
85
+ self,
86
+ req: urllib.request.Request,
87
+ fp: IO[bytes],
88
+ code: int,
89
+ msg: str,
90
+ headers: HTTPMessage,
91
+ newurl: str,
92
+ ) -> urllib.request.Request | None:
93
+ if self.attempt.cancelled:
94
+ return None
95
+ if code in (307, 308) and req.get_method() == "POST":
96
+ new = urllib.request.Request(
97
+ newurl, req.data, dict(req.headers), method="POST"
98
+ )
99
+ else:
100
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
101
+ if new and _origin(newurl) != _origin(req.full_url):
102
+ new.remove_header("Authorization")
103
+ return new
104
+
105
+
106
+ def _warn(message: str) -> None:
107
+ print(f"warn: lineage: {message}", file=sys.stderr)
108
+
109
+
110
+ class OpenLineageObserver:
111
+ """A run observer (`floe.run.events.Observer`) posting OpenLineage `RunEvent`s."""
112
+
113
+ # ponytail: unsynchronised breaker counters; add a lock when entities run in parallel.
114
+ def __init__(
115
+ self, config: LineageConfig, entities: Sequence[EntityConfig], config_path: str
116
+ ) -> None:
117
+ self.config = config
118
+ self.events = EventBuilder(config, entities, config_path)
119
+ path = (config.endpoint or "").strip() or "api/v1/lineage"
120
+ self.url = f"{config.url.rstrip('/')}/{path.lstrip('/')}"
121
+ secret = config.api_key.get_secret_value() if config.api_key else None
122
+ self.api_key = resolve_api_key(secret, os.environ.get("OPENLINEAGE_API_KEY"))
123
+ self.disabled = bool(self.api_key and ("\r" in self.api_key or "\n" in self.api_key)) # fmt: skip
124
+ if self.disabled:
125
+ _warn("api_key contains a line break; events are not sent")
126
+ self.entity_run_ids: dict[str, str] = {}
127
+ self.run_uuid: str | None = None
128
+ self.consecutive_failures = 0
129
+ self.circuit_open = False
130
+
131
+ def __call__(self, event: dict[str, Any]) -> None:
132
+ kind, ts = event["event"], event["ts_ms"]
133
+ job = self.events.run_job_name
134
+ if kind == "run_started":
135
+ self.consecutive_failures, self.circuit_open = 0, False
136
+ self.run_uuid = str(uuid.uuid4())
137
+ self._post("START", ts, self.run_uuid, job)
138
+ elif kind == "entity_started":
139
+ run_id = self.entity_run_ids[event["name"]] = str(uuid.uuid4())
140
+ self._post("START", ts, run_id, event["name"])
141
+ elif kind == "entity_finished":
142
+ name = event["name"]
143
+ run_id = self.entity_run_ids.get(name, f"{event['run_id']}.entity.{name}")
144
+ inputs, outputs = self.events.datasets(name, event)
145
+ self._post(event_type(event["status"]), ts, run_id, name, inputs, outputs)
146
+ elif kind == "run_finished":
147
+ run_id = self.run_uuid or str(uuid.uuid4())
148
+ self._post(event_type(event["status"]), ts, run_id, job)
149
+
150
+ def fail_run(self) -> None:
151
+ """A run that raised: `START` if it never started, then `FAIL` (Rust: the CLI failure path)."""
152
+ ts = time.time_ns() // 1_000_000
153
+ if self.run_uuid is None:
154
+ self({"event": "run_started", "ts_ms": ts})
155
+ self({"event": "run_finished", "status": "failed", "ts_ms": ts})
156
+
157
+ def _post(
158
+ self,
159
+ event_type: str,
160
+ ts_ms: int,
161
+ run_id: str,
162
+ job: str,
163
+ inputs: Datasets | None = None,
164
+ outputs: Datasets | None = None,
165
+ ) -> None:
166
+ if self.circuit_open or self.disabled:
167
+ return
168
+ body = self.events.body(event_type, ts_ms, run_id, job, inputs, outputs)
169
+ data = json.dumps(body).encode()
170
+ for delay in RETRY_DELAYS_S:
171
+ time.sleep(delay)
172
+ retryable = self._attempt(data)
173
+ if retryable is None:
174
+ self.consecutive_failures = 0
175
+ return
176
+ if not retryable:
177
+ break
178
+ _warn(f"POST {self.url} failed — event dropped")
179
+ self.consecutive_failures += 1
180
+ failures = self.consecutive_failures
181
+ if (
182
+ failures
183
+ >= (3 if self.config.max_failures is None else self.config.max_failures)
184
+ and not self.circuit_open
185
+ ):
186
+ self.circuit_open = True
187
+ _warn(f"disabled for this run after {failures} consecutive failures — check endpoint {self.url}") # fmt: skip
188
+
189
+ def _attempt(self, data: bytes) -> bool | None:
190
+ """None on success, else whether the failure is retryable (transport, timeout, 5xx, 429).
191
+
192
+ The timeout bounds the whole request, as reqwest's does; urllib's alone bounds each socket read.
193
+ An expired attempt is cancelled, so it never sends later (a redirect, a stale event).
194
+ """
195
+ timeout = 5 if self.config.timeout_secs is None else self.config.timeout_secs
196
+ attempt = _Attempt()
197
+ result: list[bool | None] = []
198
+ thread = threading.Thread(
199
+ target=lambda: result.append(self._send(data, timeout, attempt)),
200
+ daemon=True,
201
+ )
202
+ thread.start()
203
+ thread.join(timeout)
204
+ if thread.is_alive():
205
+ attempt.cancel()
206
+ return True # an expired attempt failed, even if its cut-off read looks like a 200 (#886)
207
+ return result[0] if result else True
208
+
209
+ def _send(self, data: bytes, timeout: float, attempt: _Attempt) -> bool | None:
210
+ headers = {"Content-Type": "application/json"}
211
+ if self.api_key:
212
+ headers["Authorization"] = f"Bearer {self.api_key}"
213
+ request = urllib.request.Request(self.url, data, headers, method="POST")
214
+ opener = urllib.request.build_opener(_Handler(attempt), _Redirect(attempt))
215
+ try:
216
+ with opener.open(request, timeout=timeout):
217
+ return None
218
+ except urllib.error.HTTPError as e:
219
+ return e.code == 429 or e.code >= 500
220
+ except Exception: # noqa: BLE001 -- the text may carry the token; never let it reach a thread hook
221
+ return True
222
+
223
+
224
+ def run_with_lineage(
225
+ path: Path,
226
+ root: RootConfig,
227
+ observed: Sequence[EntityConfig],
228
+ job: str,
229
+ observer: Callable[[dict[str, Any]], None],
230
+ *,
231
+ validate: bool,
232
+ run_id: str | None,
233
+ entities: Sequence[str],
234
+ dry_run: bool,
235
+ full_refresh: bool,
236
+ config_uri: str | None = None,
237
+ ) -> RunOutcome:
238
+ """`run`, posting OpenLineage events when the config has a `lineage` block (Rust: the CLI run path)."""
239
+ lineage = OpenLineageObserver(root.lineage, observed, job) if root.lineage else None
240
+
241
+ def observe(event: dict[str, Any]) -> None:
242
+ try:
243
+ observer(event)
244
+ finally: # a failing user observer never drops a lineage event
245
+ if lineage:
246
+ lineage(event)
247
+
248
+ try:
249
+ if validate: # after the observer: lineage sees a config that fails validation
250
+ validate_config(root)
251
+ return run(
252
+ path, root, run_id=run_id, entities=entities, dry_run=dry_run,
253
+ full_refresh=full_refresh, observer=observe, config_uri=config_uri,
254
+ ) # fmt: skip
255
+ except FloeError:
256
+ if lineage:
257
+ try:
258
+ lineage.fail_run()
259
+ except Exception as e: # noqa: BLE001 -- lineage must not mask the run error
260
+ _warn(str(e))
261
+ raise
floe/maintain.py ADDED
@@ -0,0 +1,134 @@
1
+ """`floe maintain`: Iceberg table maintenance for the entities' accepted sinks (#618, Python only)."""
2
+
3
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
4
+ # ^ pyarrow ships no type stubs
5
+
6
+ import time
7
+ from collections.abc import Sequence
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlsplit
12
+
13
+ from pyiceberg.table import Table
14
+
15
+ from floe.config.project import RootConfig
16
+ from floe.config.storage import StorageDefinition
17
+ from floe.config.validate import validate_config
18
+ from floe.errors import FloeConfigError, FloeRunError
19
+ from floe.iceberg.catalog import open_duckdb, run_error
20
+ from floe.iceberg.compact import compact
21
+ from floe.iceberg.sink import existing_table, resolve_target
22
+ from floe.run import select_entities
23
+ from floe.storage.paths import normalize_local_path, parse_s3_uri
24
+ from floe.storage.s3 import S3Client
25
+
26
+ # the retention floor (#545 decision 4): the retry guard reads snapshot summaries this recent
27
+ FLOOR_DAYS, FLOOR_SNAPSHOTS = 7, 10
28
+ # an orphan younger than the delay may be a file an in-flight `floe run` has not committed yet
29
+ ORPHAN_DELAY_DAYS, ORPHAN_FLOOR_DAYS = 3, 1
30
+ DAY = 86_400
31
+
32
+
33
+ def expire_snapshots(table: Table, older_than_days: int, retain_last: int, dry_run: bool) -> tuple[int, int]: # fmt: skip
34
+ """Expire snapshots older than the window and outside the newest `retain_last`; returns (expired, kept).
35
+
36
+ Metadata only: no file is deleted. Branch and tag heads are never expired.
37
+ """
38
+ cutoff = (time.time() - older_than_days * DAY) * 1000
39
+ newest = sorted(table.metadata.snapshots, key=lambda s: s.timestamp_ms, reverse=True) # fmt: skip
40
+ protected = {ref.snapshot_id for ref in table.metadata.refs.values()}
41
+ expired = [
42
+ s.snapshot_id for s in newest[retain_last:]
43
+ if s.timestamp_ms < cutoff and s.snapshot_id not in protected
44
+ ] # fmt: skip
45
+ if expired and not dry_run:
46
+ with run_error("iceberg snapshot expiry failed"):
47
+ table.maintenance.expire_snapshots().by_ids(expired).commit()
48
+ return len(expired), len(newest) - len(expired)
49
+
50
+
51
+ def _key(uri: str) -> str:
52
+ """`s3://b/k` -> `b/k`; `file:///p` and `/p` -> `/p`: metadata and listings spell paths differently."""
53
+ parts = urlsplit(uri)
54
+ return parts.netloc + parts.path
55
+
56
+
57
+ def _list(location: str, storage: StorageDefinition | None) -> list[tuple[str, float | None, int]]: # fmt: skip
58
+ """(uri, mtime, size) of every object under the table location."""
59
+ if location.startswith("s3://"):
60
+ bucket, key = parse_s3_uri(location)
61
+ s3 = storage if storage and storage.type == "s3" else None
62
+ client = S3Client(bucket, s3 and s3.region, s3 and s3.endpoint, s3 and s3.path_style_access) # fmt: skip
63
+ return [
64
+ (r.uri, datetime.fromisoformat(r.last_modified).timestamp() if r.last_modified else None,r.size or 0)
65
+ for r in client.list(f"{key.strip('/')}/" if key.strip("/") else "") # a bucket root lists every key
66
+ ] # fmt: skip
67
+ # ponytail: LocalClient.list is flat and has no mtime (a Rust port), so a local warehouse is walked here
68
+ files = (p for p in Path(_key(location)).rglob("*") if p.is_file())
69
+ return [(f"file://{p}", (st := p.stat()).st_mtime, st.st_size) for p in files]
70
+
71
+
72
+ def _reachable(table: Table) -> set[str]:
73
+ """Every file the table's retained metadata references."""
74
+ m, inspect = table.metadata, table.inspect
75
+ paths = [table.metadata_location, *(e.metadata_file for e in m.metadata_log), *(s.manifest_list for s in m.snapshots)] # fmt: skip
76
+ paths += [s.statistics_path for s in m.statistics] + [s.statistics_path for s in m.partition_statistics] # fmt: skip
77
+ with run_error("iceberg metadata scan failed"):
78
+ paths += inspect.all_manifests()["path"].to_pylist() + inspect.all_files()["file_path"].to_pylist() # fmt: skip
79
+ return {_key(p) for p in paths}
80
+
81
+
82
+ def remove_orphans(table: Table, storage: StorageDefinition | None, delay_days: int, dry_run: bool) -> list[tuple[str, int]]: # fmt: skip
83
+ """Delete the objects under the table location that no retained metadata references and older than the delay."""
84
+ listed = _list(table.location(), storage)
85
+ if _key(table.metadata_location) not in {_key(uri) for uri, _, _ in listed}:
86
+ # a listing that misses the current metadata would make every file look orphaned
87
+ raise FloeRunError(f"orphan scan of {table.location()} did not list the current metadata {table.metadata_location}; nothing was deleted") # fmt: skip
88
+ keep, cutoff = _reachable(table), time.time() - delay_days * DAY
89
+ orphans = [(uri, size) for uri, mtime, size in listed if _key(uri) not in keep and mtime is not None and mtime < cutoff] # fmt: skip
90
+ if not dry_run:
91
+ with run_error("iceberg orphan file delete failed"):
92
+ for uri, _ in orphans:
93
+ table.io.delete(uri)
94
+ return orphans
95
+
96
+
97
+ def maintain(path: Path, root: RootConfig, entities: Sequence[str], older_than_days: int, retain_last: int, orphan_delay_days: int, dry_run: bool, compact_tables: bool, experimental: bool) -> list[dict[str, Any]]: # fmt: skip
98
+ """Compact (experimental, opt-in), expire snapshots, then remove orphan files, of every selected entity's Iceberg table.
99
+
100
+ A request below the retention floor or the orphan delay floor fails closed.
101
+ """
102
+ if compact_tables and not experimental:
103
+ raise FloeConfigError(
104
+ "--compact is experimental: pass --experimental to run it"
105
+ )
106
+ if compact_tables and dry_run:
107
+ raise FloeConfigError("--compact has no dry run: drop --dry-run or --compact")
108
+ if older_than_days < FLOOR_DAYS or retain_last < FLOOR_SNAPSHOTS:
109
+ raise FloeConfigError(
110
+ f"snapshot expiry below the retention floor: --older-than-days must be at least {FLOOR_DAYS} "
111
+ f"and --retain-last at least {FLOOR_SNAPSHOTS} (got {older_than_days} and {retain_last})"
112
+ )
113
+ if orphan_delay_days < ORPHAN_FLOOR_DAYS:
114
+ raise FloeConfigError(f"orphan delay below the floor: --orphan-delay-days must be at least {ORPHAN_FLOOR_DAYS} (got {orphan_delay_days})") # fmt: skip
115
+ validate_config(root)
116
+ config_dir = normalize_local_path(path.resolve().parent)
117
+ results: list[dict[str, Any]] = []
118
+ for entity in select_entities(root, entities):
119
+ if entity.sink.accepted.format != "iceberg":
120
+ continue
121
+ target = resolve_target(root.storages, root.catalogs, config_dir, entity)
122
+ table = existing_table(target)
123
+ compacted = None
124
+ if table and compact_tables:
125
+ with open_duckdb(target.catalog, target.storage) as con:
126
+ compacted = compact(table, con)
127
+ expired, kept = expire_snapshots(table, older_than_days, retain_last, dry_run) if table else (0, 0) # fmt: skip
128
+ orphans = remove_orphans(table, target.storage, orphan_delay_days, dry_run) if table else [] # fmt: skip
129
+ results.append({
130
+ "entity": entity.name, "table": f"{target.namespace}.{target.table}", "compaction": compacted,
131
+ "expired_snapshots": expired, "kept_snapshots": kept,
132
+ "orphan_files": [uri for uri, _ in orphans], "orphan_bytes": sum(size for _, size in orphans),
133
+ }) # fmt: skip
134
+ return results