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/glob.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Port of the Rust `glob` crate 0.3.4: `Pattern` matcher and the `glob` walker.
|
|
2
|
+
|
|
3
|
+
Behaviour (error text, ordering, `**` collapsing, no silently skipped directories)
|
|
4
|
+
follows the crate so a pattern selects the same files in Rust and Python.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import PurePath
|
|
10
|
+
|
|
11
|
+
from floe.errors import FloeError
|
|
12
|
+
|
|
13
|
+
_SEPS = frozenset(s for s in (os.sep, os.altsep) if s)
|
|
14
|
+
_MATCH, _SUB_NO, _ALL_NO = 0, 1, 2 # Match / SubPattern / EntirePattern DoesntMatch
|
|
15
|
+
_DONE = -1 # the crate's usize::MAX index: already matched by fill_todo
|
|
16
|
+
|
|
17
|
+
_E_WILD = "wildcards are either regular `*` or recursive `**`"
|
|
18
|
+
_E_REC = "recursive wildcards must form a single path component"
|
|
19
|
+
_E_RANGE = "invalid range pattern"
|
|
20
|
+
|
|
21
|
+
_Spec = tuple[str, str | None] # (char, None) or (start, end)
|
|
22
|
+
_Token = tuple[str, str, tuple[_Spec, ...]] # (kind, char, specs); kind: c ? * ** in ex
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PatternError(FloeError):
|
|
26
|
+
def __init__(self, pos: int, msg: str) -> None:
|
|
27
|
+
super().__init__(f"Pattern syntax error near position {pos}: {msg}")
|
|
28
|
+
self.pos = pos
|
|
29
|
+
self.msg = msg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class MatchOptions:
|
|
34
|
+
"""Defaults are the crate's `MatchOptions::new()`."""
|
|
35
|
+
|
|
36
|
+
case_sensitive: bool = True
|
|
37
|
+
require_literal_separator: bool = False
|
|
38
|
+
require_literal_leading_dot: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DEFAULT = MatchOptions()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _specs(s: str) -> tuple[_Spec, ...]:
|
|
45
|
+
out: list[_Spec] = []
|
|
46
|
+
i = 0
|
|
47
|
+
while i < len(s):
|
|
48
|
+
if i + 3 <= len(s) and s[i + 1] == "-":
|
|
49
|
+
out.append((s[i], s[i + 2]))
|
|
50
|
+
i += 3
|
|
51
|
+
else:
|
|
52
|
+
out.append((s[i], None))
|
|
53
|
+
i += 1
|
|
54
|
+
return tuple(out)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _chars_eq(a: str, b: str, case_sensitive: bool) -> bool:
|
|
58
|
+
if os.name == "nt" and a in _SEPS and b in _SEPS:
|
|
59
|
+
return True
|
|
60
|
+
if not case_sensitive and a.isascii() and b.isascii():
|
|
61
|
+
return a.lower() == b.lower()
|
|
62
|
+
return a == b
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _in_specs(specs: tuple[_Spec, ...], c: str, o: MatchOptions) -> bool:
|
|
66
|
+
for start, end in specs:
|
|
67
|
+
if end is None:
|
|
68
|
+
if _chars_eq(c, start, o.case_sensitive):
|
|
69
|
+
return True
|
|
70
|
+
continue
|
|
71
|
+
if not o.case_sensitive and c.isascii() and start.isascii() and end.isascii():
|
|
72
|
+
lo, hi = start.lower(), end.lower()
|
|
73
|
+
# only fold case when both ends are letters, as the crate does
|
|
74
|
+
if lo != lo.upper() and hi != hi.upper() and lo <= c.lower() <= hi:
|
|
75
|
+
return True
|
|
76
|
+
if start <= c <= end:
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Pattern:
|
|
82
|
+
def __init__(self, pattern: str) -> None:
|
|
83
|
+
s, n, i = pattern, len(pattern), 0
|
|
84
|
+
tokens: list[_Token] = []
|
|
85
|
+
self.is_recursive = self.has_metachars = False
|
|
86
|
+
while i < n:
|
|
87
|
+
c = s[i]
|
|
88
|
+
if c == "?":
|
|
89
|
+
self.has_metachars = True
|
|
90
|
+
tokens.append(("?", "", ()))
|
|
91
|
+
i += 1
|
|
92
|
+
elif c == "*":
|
|
93
|
+
self.has_metachars = True
|
|
94
|
+
old = i
|
|
95
|
+
while i < n and s[i] == "*":
|
|
96
|
+
i += 1
|
|
97
|
+
count = i - old
|
|
98
|
+
if count > 2:
|
|
99
|
+
raise PatternError(old + 2, _E_WILD)
|
|
100
|
+
if count < 2:
|
|
101
|
+
tokens.append(("*", "", ()))
|
|
102
|
+
continue
|
|
103
|
+
if i == 2 or s[i - count - 1] in _SEPS:
|
|
104
|
+
if i < n and s[i] in _SEPS:
|
|
105
|
+
i += 1
|
|
106
|
+
elif i != n:
|
|
107
|
+
raise PatternError(i, _E_REC)
|
|
108
|
+
else:
|
|
109
|
+
raise PatternError(old - 1, _E_REC)
|
|
110
|
+
# the crate's `tokens_len > 1` is kept: "**/**" in one string stays 2
|
|
111
|
+
if not (len(tokens) > 1 and tokens[-1][0] == "**"):
|
|
112
|
+
self.is_recursive = True
|
|
113
|
+
tokens.append(("**", "", ()))
|
|
114
|
+
elif c == "[":
|
|
115
|
+
self.has_metachars = True
|
|
116
|
+
if i + 4 <= n and s[i + 1] == "!":
|
|
117
|
+
k = s.find("]", i + 3)
|
|
118
|
+
if k >= 0:
|
|
119
|
+
tokens.append(("ex", "", _specs(s[i + 2 : k])))
|
|
120
|
+
i = k + 1
|
|
121
|
+
continue
|
|
122
|
+
elif i + 3 <= n and s[i + 1] != "!":
|
|
123
|
+
k = s.find("]", i + 2)
|
|
124
|
+
if k >= 0:
|
|
125
|
+
tokens.append(("in", "", _specs(s[i + 1 : k])))
|
|
126
|
+
i = k + 1
|
|
127
|
+
continue
|
|
128
|
+
raise PatternError(i, _E_RANGE)
|
|
129
|
+
else:
|
|
130
|
+
tokens.append(("c", c, ()))
|
|
131
|
+
i += 1
|
|
132
|
+
self.original = pattern
|
|
133
|
+
self.tokens = tokens
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def escape(s: str) -> str:
|
|
137
|
+
return "".join(f"[{c}]" if c in "?*[]" else c for c in s)
|
|
138
|
+
|
|
139
|
+
def matches_with(self, path: str, options: MatchOptions = _DEFAULT) -> bool:
|
|
140
|
+
return self._from(True, path, 0, 0, options) == _MATCH
|
|
141
|
+
|
|
142
|
+
def _from(self, follows: bool, s: str, p: int, ti: int, o: MatchOptions) -> int:
|
|
143
|
+
for t in range(ti, len(self.tokens)):
|
|
144
|
+
kind, ch, specs = self.tokens[t]
|
|
145
|
+
if kind in ("*", "**"):
|
|
146
|
+
m = self._from(follows, s, p, t + 1, o)
|
|
147
|
+
if m != _SUB_NO:
|
|
148
|
+
return m
|
|
149
|
+
while p < len(s):
|
|
150
|
+
c = s[p]
|
|
151
|
+
p += 1
|
|
152
|
+
if follows and o.require_literal_leading_dot and c == ".":
|
|
153
|
+
return _SUB_NO
|
|
154
|
+
follows = c in _SEPS
|
|
155
|
+
if kind == "**" and not follows:
|
|
156
|
+
continue
|
|
157
|
+
if kind == "*" and o.require_literal_separator and follows:
|
|
158
|
+
return _SUB_NO
|
|
159
|
+
m = self._from(follows, s, p, t + 1, o)
|
|
160
|
+
if m != _SUB_NO:
|
|
161
|
+
return m
|
|
162
|
+
continue
|
|
163
|
+
if p >= len(s):
|
|
164
|
+
return _ALL_NO
|
|
165
|
+
c = s[p]
|
|
166
|
+
p += 1
|
|
167
|
+
is_sep = c in _SEPS
|
|
168
|
+
if kind != "c" and (
|
|
169
|
+
(o.require_literal_separator and is_sep)
|
|
170
|
+
or (follows and o.require_literal_leading_dot and c == ".")
|
|
171
|
+
):
|
|
172
|
+
return _SUB_NO
|
|
173
|
+
if kind == "in":
|
|
174
|
+
ok = _in_specs(specs, c, o)
|
|
175
|
+
elif kind == "ex":
|
|
176
|
+
ok = not _in_specs(specs, c, o)
|
|
177
|
+
elif kind == "c":
|
|
178
|
+
ok = _chars_eq(c, ch, o.case_sensitive)
|
|
179
|
+
else:
|
|
180
|
+
ok = True
|
|
181
|
+
if not ok:
|
|
182
|
+
return _SUB_NO
|
|
183
|
+
follows = is_sep
|
|
184
|
+
return _MATCH if p >= len(s) else _SUB_NO
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_Entry = tuple[str, bool, int] # (path, is_directory, pattern index)
|
|
188
|
+
_Todo = list[_Entry | FloeError]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _glob_error(path: str, e: OSError) -> FloeError:
|
|
192
|
+
msg = f"{e.strerror} (os error {e.errno})" if e.errno else str(e)
|
|
193
|
+
return FloeError(f"attempting to read `{path}` resulted in an error: {msg}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_dir(e: "os.DirEntry[str]") -> bool:
|
|
197
|
+
try:
|
|
198
|
+
return e.is_dir()
|
|
199
|
+
except OSError:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _is_utf8(name: str) -> bool:
|
|
204
|
+
return not any("\udc80" <= c <= "\udcff" for c in name) # surrogate-escaped bytes
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _fill_todo(
|
|
208
|
+
todo: _Todo, pats: list[Pattern], idx: int, path: str, is_dir: bool, o: MatchOptions
|
|
209
|
+
) -> None:
|
|
210
|
+
def add(p: str) -> None:
|
|
211
|
+
d = os.path.isdir(p)
|
|
212
|
+
if idx + 1 == len(pats):
|
|
213
|
+
todo.append((p, d, _DONE))
|
|
214
|
+
else:
|
|
215
|
+
_fill_todo(todo, pats, idx + 1, p, d, o)
|
|
216
|
+
|
|
217
|
+
pat = pats[idx]
|
|
218
|
+
if not pat.has_metachars:
|
|
219
|
+
s = pat.original
|
|
220
|
+
special = s in (".", "..")
|
|
221
|
+
nxt = s if path == "." else os.path.join(path, s)
|
|
222
|
+
if (special and is_dir) or (not special and os.path.lexists(nxt)):
|
|
223
|
+
add(nxt)
|
|
224
|
+
elif is_dir:
|
|
225
|
+
try:
|
|
226
|
+
with os.scandir(path) as it:
|
|
227
|
+
kids = [(e.name, _is_dir(e)) for e in it]
|
|
228
|
+
except OSError as e:
|
|
229
|
+
todo.append(_glob_error(path, e))
|
|
230
|
+
return
|
|
231
|
+
if o.require_literal_leading_dot:
|
|
232
|
+
kids = [k for k in kids if not k[0].startswith(".")]
|
|
233
|
+
kids.sort(key=lambda k: os.fsencode(k[0]), reverse=True)
|
|
234
|
+
todo.extend(
|
|
235
|
+
(k[0] if path == "." else os.path.join(path, k[0]), k[1], idx) for k in kids
|
|
236
|
+
)
|
|
237
|
+
# `.` and `..` only match a pattern with an explicit leading dot
|
|
238
|
+
if pat.tokens and pat.tokens[0] == ("c", ".", ()):
|
|
239
|
+
for special in (".", ".."):
|
|
240
|
+
if pat.matches_with(special, o):
|
|
241
|
+
add(os.path.join(path, special))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def glob(pattern: str, options: MatchOptions = _DEFAULT) -> list[str]:
|
|
245
|
+
"""Paths matching `pattern` in the crate's order.
|
|
246
|
+
|
|
247
|
+
Raises `PatternError` on bad syntax and `FloeError` (the crate's `GlobError`
|
|
248
|
+
text) on the first directory that cannot be read.
|
|
249
|
+
"""
|
|
250
|
+
Pattern(pattern) # whole-pattern syntax check first, as the crate does
|
|
251
|
+
drive = os.path.splitdrive(pattern)[0]
|
|
252
|
+
root_len = len(drive) + (1 if pattern[len(drive) : len(drive) + 1] in _SEPS else 0)
|
|
253
|
+
body = pattern[root_len:]
|
|
254
|
+
if os.altsep:
|
|
255
|
+
body = body.replace(os.altsep, os.sep)
|
|
256
|
+
parts = body.split(os.sep)
|
|
257
|
+
if parts[-1] == "":
|
|
258
|
+
parts.pop()
|
|
259
|
+
pats = [Pattern(c) for c in parts]
|
|
260
|
+
if root_len == len(pattern):
|
|
261
|
+
pats.append(Pattern(""))
|
|
262
|
+
require_dir = pattern[-1:] in _SEPS
|
|
263
|
+
scope = pattern[:root_len] or "."
|
|
264
|
+
todo: _Todo = []
|
|
265
|
+
_fill_todo(todo, pats, 0, scope, os.path.isdir(scope), options)
|
|
266
|
+
out: list[str] = []
|
|
267
|
+
last = len(pats) - 1
|
|
268
|
+
while todo:
|
|
269
|
+
item = todo.pop()
|
|
270
|
+
if isinstance(item, FloeError):
|
|
271
|
+
raise item
|
|
272
|
+
path, is_dir, idx = item
|
|
273
|
+
if idx == _DONE:
|
|
274
|
+
if not require_dir or is_dir:
|
|
275
|
+
out.append(path)
|
|
276
|
+
continue
|
|
277
|
+
if pats[idx].is_recursive:
|
|
278
|
+
nxt = idx
|
|
279
|
+
while nxt + 1 < len(pats) and pats[nxt + 1].is_recursive:
|
|
280
|
+
nxt += 1
|
|
281
|
+
if is_dir:
|
|
282
|
+
_fill_todo(todo, pats, nxt, path, is_dir, options)
|
|
283
|
+
if nxt == last:
|
|
284
|
+
out.append(path)
|
|
285
|
+
continue
|
|
286
|
+
elif nxt == last:
|
|
287
|
+
continue
|
|
288
|
+
idx = nxt + 1
|
|
289
|
+
name = PurePath(path).name
|
|
290
|
+
# the crate skips non-UTF-8 names
|
|
291
|
+
if name in ("", "..") or not _is_utf8(name):
|
|
292
|
+
continue
|
|
293
|
+
if not pats[idx].matches_with(name, options):
|
|
294
|
+
continue
|
|
295
|
+
if idx == last:
|
|
296
|
+
if not require_dir or is_dir:
|
|
297
|
+
out.append(path)
|
|
298
|
+
else:
|
|
299
|
+
_fill_todo(todo, pats, idx + 1, path, is_dir, options)
|
|
300
|
+
return out
|
floe/iceberg/catalog.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Polaris REST catalog access, ported from `io/write/iceberg/rest.rs` and `iceberg/schema.rs`."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Generator, Sequence
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import duckdb
|
|
10
|
+
from pyiceberg.catalog import Catalog, load_catalog
|
|
11
|
+
from pyiceberg.partitioning import PartitionField, PartitionSpec
|
|
12
|
+
from pyiceberg.schema import Schema
|
|
13
|
+
from pyiceberg.table import Table
|
|
14
|
+
from pyiceberg.transforms import parse_transform
|
|
15
|
+
from pyiceberg.types import NestedField
|
|
16
|
+
|
|
17
|
+
from floe.config.storage import CatalogDefinition, StorageDefinition
|
|
18
|
+
from floe.config.types import SchemaEvolutionMode
|
|
19
|
+
from floe.errors import FloeConfigError, FloeRunError
|
|
20
|
+
|
|
21
|
+
# (column, transform) pairs, as in the entity's `sink.accepted.partition_spec`;
|
|
22
|
+
# a bucket is `bucket[N]` (PyIceberg's notation) with N its `num_buckets` (D12)
|
|
23
|
+
PartitionFields = Sequence[tuple[str, str]]
|
|
24
|
+
|
|
25
|
+
_TRANSFORMS = frozenset({"identity", "year", "month", "day", "hour"})
|
|
26
|
+
# first id of a partition field, as in the Iceberg spec
|
|
27
|
+
_PARTITION_FIELD_ID_START = 1000
|
|
28
|
+
DUCKDB_CATALOG = "floe_catalog"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def transform_name(field: PartitionField) -> str:
|
|
32
|
+
return str(field.transform) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] -- PyIceberg leaves Transform unparametrized
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def run_error(context: str) -> Generator[None]:
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
except Exception as e: # PyIceberg has no common base exception
|
|
40
|
+
raise FloeRunError(f"{context}: {e}") from e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _expand_env_part(part: str, catalog: str) -> str:
|
|
44
|
+
if not part.startswith("${"):
|
|
45
|
+
return part
|
|
46
|
+
if not part.endswith("}"):
|
|
47
|
+
raise FloeRunError(
|
|
48
|
+
f"rest iceberg catalog {catalog} credential has unclosed env placeholder"
|
|
49
|
+
)
|
|
50
|
+
name = part[2:-1]
|
|
51
|
+
if not name or "{" in name or "}" in name:
|
|
52
|
+
raise FloeRunError(
|
|
53
|
+
f"rest iceberg catalog {catalog} credential has invalid env placeholder"
|
|
54
|
+
)
|
|
55
|
+
value = os.environ.get(name)
|
|
56
|
+
if value is None:
|
|
57
|
+
raise FloeRunError(
|
|
58
|
+
f"rest iceberg catalog {catalog} credential references env var {name} which is not set"
|
|
59
|
+
)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _credential_props(catalog: str, credential: str) -> dict[str, str]:
|
|
64
|
+
"""`token:<v>` is a bearer token; `client_credentials:<id>:<secret>` and a plain value are OAuth2."""
|
|
65
|
+
if "${" in credential:
|
|
66
|
+
credential = ":".join(
|
|
67
|
+
_expand_env_part(p, catalog) for p in credential.split(":")
|
|
68
|
+
)
|
|
69
|
+
if credential.startswith("token:"):
|
|
70
|
+
return {"token": credential.removeprefix("token:")}
|
|
71
|
+
return {"credential": credential.removeprefix("client_credentials:")}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def open_catalog(
|
|
75
|
+
definition: CatalogDefinition, storage: StorageDefinition | None = None
|
|
76
|
+
) -> Catalog:
|
|
77
|
+
"""Credentials for S3 come from the environment; the catalog never vends them (as in Rust)."""
|
|
78
|
+
props = {
|
|
79
|
+
"type": "rest",
|
|
80
|
+
"uri": definition.uri,
|
|
81
|
+
"header.X-Iceberg-Access-Delegation": "",
|
|
82
|
+
}
|
|
83
|
+
if definition.warehouse is not None:
|
|
84
|
+
props["warehouse"] = definition.warehouse
|
|
85
|
+
if definition.credential is not None:
|
|
86
|
+
props.update(
|
|
87
|
+
_credential_props(definition.name, definition.credential.get_secret_value())
|
|
88
|
+
)
|
|
89
|
+
if definition.oauth2_server_uri is not None:
|
|
90
|
+
props["oauth2-server-uri"] = definition.oauth2_server_uri
|
|
91
|
+
if definition.scope is not None:
|
|
92
|
+
props["scope"] = definition.scope
|
|
93
|
+
if storage is not None:
|
|
94
|
+
if storage.endpoint is not None:
|
|
95
|
+
props["s3.endpoint"] = storage.endpoint
|
|
96
|
+
if storage.region is not None:
|
|
97
|
+
props["s3.region"] = storage.region
|
|
98
|
+
if storage.path_style_access is not None:
|
|
99
|
+
props["s3.force-virtual-addressing"] = str(
|
|
100
|
+
not storage.path_style_access
|
|
101
|
+
).lower()
|
|
102
|
+
with run_error("rest catalog init failed"):
|
|
103
|
+
return load_catalog(definition.name, **props)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sql_literal(value: str) -> str:
|
|
107
|
+
return "'" + value.replace("'", "''") + "'"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sql_options(options: dict[str, str]) -> str:
|
|
111
|
+
return ", ".join(f"{k} {_sql_literal(v)}" for k, v in options.items())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _duckdb_setup(
|
|
115
|
+
definition: CatalogDefinition, storage: StorageDefinition | None
|
|
116
|
+
) -> list[str]:
|
|
117
|
+
"""Secrets, then the ATTACH; S3 credentials come from the AWS chain, as for PyIceberg."""
|
|
118
|
+
statements: list[str] = []
|
|
119
|
+
auth: dict[str, str] = {}
|
|
120
|
+
if definition.credential is not None:
|
|
121
|
+
props = _credential_props(
|
|
122
|
+
definition.name, definition.credential.get_secret_value()
|
|
123
|
+
)
|
|
124
|
+
if "token" in props:
|
|
125
|
+
auth["TOKEN"] = props["token"]
|
|
126
|
+
else:
|
|
127
|
+
client_id, colon, secret = props["credential"].partition(":")
|
|
128
|
+
if (
|
|
129
|
+
not colon
|
|
130
|
+
): # PyIceberg sends it as a secret alone; DuckDB requires a client id
|
|
131
|
+
raise FloeConfigError(
|
|
132
|
+
f"rest iceberg catalog {definition.name} credential needs client_id:client_secret "
|
|
133
|
+
"for merges (DuckDB has no secret-only OAuth2)"
|
|
134
|
+
)
|
|
135
|
+
auth |= {
|
|
136
|
+
"CLIENT_ID": client_id,
|
|
137
|
+
"CLIENT_SECRET": secret,
|
|
138
|
+
"OAUTH2_SERVER_URI": definition.oauth2_server_uri
|
|
139
|
+
or f"{definition.uri.rstrip('/')}/v1/oauth/tokens",
|
|
140
|
+
}
|
|
141
|
+
if definition.scope is not None:
|
|
142
|
+
auth["OAUTH2_SCOPE"] = definition.scope
|
|
143
|
+
if storage is not None and storage.type == "s3":
|
|
144
|
+
s3 = {"PROVIDER": "credential_chain"}
|
|
145
|
+
if storage.region is not None:
|
|
146
|
+
s3["REGION"] = storage.region
|
|
147
|
+
if storage.endpoint is not None:
|
|
148
|
+
endpoint = urlsplit(storage.endpoint)
|
|
149
|
+
s3["ENDPOINT"] = endpoint.netloc or endpoint.path
|
|
150
|
+
s3["USE_SSL"] = str(endpoint.scheme != "http").lower()
|
|
151
|
+
if storage.path_style_access:
|
|
152
|
+
s3["URL_STYLE"] = "path"
|
|
153
|
+
statements.append(f"CREATE SECRET (TYPE s3, {_sql_options(s3)})")
|
|
154
|
+
# no access delegation, as in `open_catalog`
|
|
155
|
+
attach = {"TYPE": "iceberg", "ENDPOINT": definition.uri, "ACCESS_DELEGATION_MODE": "none"} # fmt: skip
|
|
156
|
+
if auth:
|
|
157
|
+
statements.append(f"CREATE SECRET floe_auth (TYPE iceberg, {_sql_options(auth)})") # fmt: skip
|
|
158
|
+
attach["SECRET"] = "floe_auth"
|
|
159
|
+
else:
|
|
160
|
+
attach["AUTHORIZATION_TYPE"] = "none"
|
|
161
|
+
warehouse = _sql_literal(definition.warehouse or "")
|
|
162
|
+
statements.append(f"ATTACH {warehouse} AS {DUCKDB_CATALOG} ({_sql_options(attach)})") # fmt: skip
|
|
163
|
+
return statements
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def open_duckdb(
|
|
167
|
+
definition: CatalogDefinition, storage: StorageDefinition | None = None
|
|
168
|
+
) -> duckdb.DuckDBPyConnection:
|
|
169
|
+
"""DuckDB with the catalog attached as `DUCKDB_CATALOG`."""
|
|
170
|
+
statements = _duckdb_setup(definition, storage)
|
|
171
|
+
with run_error("duckdb catalog attach failed"):
|
|
172
|
+
# ponytail: DuckDB's default memory_limit and spill dir; set the ADR's limits when the run wires merges
|
|
173
|
+
con = duckdb.connect()
|
|
174
|
+
for statement in statements:
|
|
175
|
+
con.execute(statement)
|
|
176
|
+
return con
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_partition_spec(
|
|
180
|
+
schema: Schema, fields: PartitionFields, entity: str
|
|
181
|
+
) -> PartitionSpec:
|
|
182
|
+
spec_fields: list[PartitionField] = []
|
|
183
|
+
for index, (column, transform) in enumerate(fields):
|
|
184
|
+
column = column.strip()
|
|
185
|
+
try:
|
|
186
|
+
source = schema.find_field(column)
|
|
187
|
+
except ValueError:
|
|
188
|
+
raise FloeRunError(
|
|
189
|
+
f"entity.name={entity} iceberg partition_spec column {column} was not found in runtime schema"
|
|
190
|
+
) from None
|
|
191
|
+
transform = transform.strip().lower()
|
|
192
|
+
bucket = re.fullmatch(r"bucket\[[1-9][0-9]*\]", transform)
|
|
193
|
+
if bucket is None and transform not in _TRANSFORMS:
|
|
194
|
+
raise FloeRunError(
|
|
195
|
+
f"entity.name={entity} iceberg partition_spec column {column} has unsupported runtime transform {transform}"
|
|
196
|
+
)
|
|
197
|
+
spec_fields.append(
|
|
198
|
+
PartitionField(
|
|
199
|
+
source_id=source.field_id,
|
|
200
|
+
field_id=_PARTITION_FIELD_ID_START + index,
|
|
201
|
+
transform=parse_transform(transform),
|
|
202
|
+
name=column
|
|
203
|
+
if transform == "identity"
|
|
204
|
+
else f"{column}_{'bucket' if bucket else transform}",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return PartitionSpec(*spec_fields)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _describe_field(field: NestedField) -> str:
|
|
211
|
+
required = "required" if field.required else "optional"
|
|
212
|
+
return f"{field.name}:{field.field_type}:{required}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_schema_matches(existing: Schema, expected: Schema, entity: str) -> None:
|
|
216
|
+
"""Rust has no Iceberg schema evolution: any difference fails (see the questions on #650)."""
|
|
217
|
+
if len(existing.fields) != len(expected.fields):
|
|
218
|
+
raise FloeRunError(
|
|
219
|
+
f"entity.name={entity} iceberg schema evolution is not supported "
|
|
220
|
+
f"(column count differs: existing={len(existing.fields)}, incoming={len(expected.fields)})"
|
|
221
|
+
)
|
|
222
|
+
for index, (old, new) in enumerate(zip(existing.fields, expected.fields)):
|
|
223
|
+
if (old.name, old.required, old.field_type) != (
|
|
224
|
+
new.name,
|
|
225
|
+
new.required,
|
|
226
|
+
new.field_type,
|
|
227
|
+
):
|
|
228
|
+
raise FloeRunError(
|
|
229
|
+
f"entity.name={entity} iceberg schema evolution is not supported "
|
|
230
|
+
f"(column {index} differs: existing={_describe_field(old)} incoming={_describe_field(new)})"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def additive_columns(
|
|
235
|
+
existing: Schema, expected: Schema, entity: str
|
|
236
|
+
) -> list[NestedField]:
|
|
237
|
+
"""Rust's Delta rules (`merge/shared.rs`) for `add_columns`: new columns only."""
|
|
238
|
+
incoming = {f.name: f for f in expected.fields}
|
|
239
|
+
incompatible: list[str] = []
|
|
240
|
+
for old in existing.fields:
|
|
241
|
+
new = incoming.get(old.name)
|
|
242
|
+
if new is None:
|
|
243
|
+
incompatible.append(f"missing existing column {old.name}")
|
|
244
|
+
continue
|
|
245
|
+
if new.field_type != old.field_type:
|
|
246
|
+
incompatible.append(
|
|
247
|
+
f"column {old.name} type changed from {old.field_type} to {new.field_type}"
|
|
248
|
+
)
|
|
249
|
+
if old.required and not new.required:
|
|
250
|
+
incompatible.append(
|
|
251
|
+
f"column {old.name} nullability changed from non-nullable to nullable"
|
|
252
|
+
)
|
|
253
|
+
if incompatible:
|
|
254
|
+
raise FloeRunError(
|
|
255
|
+
f"entity.name={entity} iceberg schema evolution failed: add_columns supports additive changes only; "
|
|
256
|
+
f"incompatible changes detected: {'; '.join(incompatible)}"
|
|
257
|
+
)
|
|
258
|
+
known = {f.name for f in existing.fields}
|
|
259
|
+
return [f for f in expected.fields if f.name not in known]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _describe_spec(spec: PartitionSpec) -> str:
|
|
263
|
+
if not spec.fields:
|
|
264
|
+
return "unpartitioned"
|
|
265
|
+
return ",".join(
|
|
266
|
+
f"{f.source_id}:{f.name}:{transform_name(f).capitalize()}" for f in spec.fields
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _key(spec: PartitionSpec) -> list[tuple[int, str, str]]:
|
|
271
|
+
return [(f.source_id, f.name, transform_name(f)) for f in spec.fields]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def check_partition_spec_matches(
|
|
275
|
+
existing: PartitionSpec, expected: PartitionSpec | None, entity: str
|
|
276
|
+
) -> None:
|
|
277
|
+
if expected is None:
|
|
278
|
+
return
|
|
279
|
+
if _key(existing) != _key(expected):
|
|
280
|
+
raise FloeRunError(
|
|
281
|
+
f"entity.name={entity} iceberg partition spec evolution is not supported "
|
|
282
|
+
f"(existing={_describe_spec(existing)} incoming={_describe_spec(expected)})"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def ensure_table(
|
|
287
|
+
catalog: Catalog,
|
|
288
|
+
namespace: str,
|
|
289
|
+
name: str,
|
|
290
|
+
schema: Schema,
|
|
291
|
+
entity: str,
|
|
292
|
+
location: str | None = None,
|
|
293
|
+
partition_fields: PartitionFields | None = None,
|
|
294
|
+
schema_evolution: SchemaEvolutionMode = "strict",
|
|
295
|
+
) -> Table:
|
|
296
|
+
"""Create the namespace and table if missing; otherwise check the table matches (Rust's append path).
|
|
297
|
+
|
|
298
|
+
With `add_columns`, columns new in `schema` are added as optional (D13); partition changes stay rejected.
|
|
299
|
+
"""
|
|
300
|
+
ident = (namespace,)
|
|
301
|
+
with run_error("rest catalog namespace_exists check failed"):
|
|
302
|
+
namespace_exists = catalog.namespace_exists(ident)
|
|
303
|
+
if not namespace_exists:
|
|
304
|
+
with run_error("rest catalog namespace create failed"):
|
|
305
|
+
catalog.create_namespace(ident)
|
|
306
|
+
identifier = (namespace, name)
|
|
307
|
+
with run_error("rest catalog table_exists check failed"):
|
|
308
|
+
exists = catalog.table_exists(identifier)
|
|
309
|
+
if exists:
|
|
310
|
+
with run_error("rest catalog load_table failed"):
|
|
311
|
+
table = catalog.load_table(identifier)
|
|
312
|
+
added: list[NestedField] = []
|
|
313
|
+
if schema_evolution == "add_columns":
|
|
314
|
+
added = additive_columns(table.schema(), schema, entity)
|
|
315
|
+
# field ids of `schema` may differ from the table's once columns are added
|
|
316
|
+
check_schema = table.schema()
|
|
317
|
+
else:
|
|
318
|
+
check_schema_matches(table.schema(), schema, entity)
|
|
319
|
+
check_schema = schema
|
|
320
|
+
check_partition_spec_matches(
|
|
321
|
+
table.spec(),
|
|
322
|
+
None
|
|
323
|
+
if partition_fields is None
|
|
324
|
+
else build_partition_spec(check_schema, partition_fields, entity),
|
|
325
|
+
entity,
|
|
326
|
+
)
|
|
327
|
+
if added:
|
|
328
|
+
with run_error("rest catalog update_schema failed"):
|
|
329
|
+
with table.update_schema() as update:
|
|
330
|
+
for field in added:
|
|
331
|
+
update.add_column(field.name, field.field_type, required=False)
|
|
332
|
+
table.refresh()
|
|
333
|
+
return table
|
|
334
|
+
spec = (
|
|
335
|
+
None
|
|
336
|
+
if partition_fields is None
|
|
337
|
+
else build_partition_spec(schema, partition_fields, entity)
|
|
338
|
+
)
|
|
339
|
+
with run_error("rest catalog create_table failed"):
|
|
340
|
+
return catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
|
|
341
|
+
identifier,
|
|
342
|
+
schema,
|
|
343
|
+
location=location,
|
|
344
|
+
partition_spec=spec if spec is not None else PartitionSpec(),
|
|
345
|
+
)
|