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/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
@@ -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
+ )