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/config/validate.py
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
"""Entity validation, ported from `config/validate.rs` (`validate_sink`, `validate_schema` and their helpers)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from floe import __version__
|
|
6
|
+
from floe.checks.cast import decimal_type
|
|
7
|
+
from floe.checks.normalize import normalize_name, output_names
|
|
8
|
+
from floe.config.project import (
|
|
9
|
+
LineageConfig,
|
|
10
|
+
RootConfig,
|
|
11
|
+
config_version,
|
|
12
|
+
validate_domains,
|
|
13
|
+
)
|
|
14
|
+
from floe.config.types import EntityConfig, WriteMode, with_partition_columns
|
|
15
|
+
from floe.config.yaml import rust_debug
|
|
16
|
+
from floe.errors import FloeConfigError
|
|
17
|
+
from floe.pii import token_n
|
|
18
|
+
from floe.read.json import parse_selector as parse_json_selector
|
|
19
|
+
from floe.read.xml import parse_selector
|
|
20
|
+
|
|
21
|
+
MIN_VERSION = (0, 1)
|
|
22
|
+
QUALIFIED_VERSION = (0, 3)
|
|
23
|
+
# D21: root.version is the minimum Floe release (major.minor) the config targets.
|
|
24
|
+
FLOE_VERSION = config_version(".".join(__version__.split(".")[:2]))
|
|
25
|
+
MAX_JSON_COLUMNS = 1024
|
|
26
|
+
CAST_MODES = ("strict", "coerce")
|
|
27
|
+
SOURCE_FORMATS = ("csv", "tsv", "parquet", "json", "orc", "avro", "xlsx", "xml")
|
|
28
|
+
REMOTE_URIS = tuple(f"{s}://" for s in ("s3", "gs", "gcs", "abfs", "abfss", "az"))
|
|
29
|
+
STORAGE_TYPES = ("local", "s3", "adls", "gcs")
|
|
30
|
+
NORMALIZE_STRATEGIES = ("snake_case", "lower", "camel_case", "none")
|
|
31
|
+
MISMATCH_MISSING = ("reject_file", "fill_nulls")
|
|
32
|
+
MISMATCH_EXTRA = ("reject_file", "ignore")
|
|
33
|
+
# D10 (#619): Rust 0.7 also lists `time`; it is not supported in 0.8.
|
|
34
|
+
COLUMN_TYPES = ("string", "number", "boolean", "datetime", "date")
|
|
35
|
+
SINK_FORMATS = ("parquet", "iceberg")
|
|
36
|
+
PARQUET_COMPRESSIONS = ("snappy", "gzip", "zstd", "uncompressed")
|
|
37
|
+
PARTITION_TRANSFORMS = ("identity", "year", "month", "day", "hour", "bucket") # D12
|
|
38
|
+
PARTITION_TOKENS = ("yyyy", "MM", "dd", "HH", "batch") # D28
|
|
39
|
+
PARTITION_TOKEN = re.compile(r"\{([^{}]*)\}")
|
|
40
|
+
# D2 (#619): Iceberg accepts the merge modes; Rust 0.7 allows them for Delta (D1) and DuckDB (D1) only.
|
|
41
|
+
SINK_WRITE_MODES: dict[str, tuple[WriteMode, ...]] = {
|
|
42
|
+
"parquet": ("overwrite", "append"),
|
|
43
|
+
"iceberg": ("overwrite", "append", "merge_scd1", "merge_scd2"),
|
|
44
|
+
}
|
|
45
|
+
SCD2_DEFAULTS = {
|
|
46
|
+
"current_flag_column": "__floe_is_current",
|
|
47
|
+
"valid_from_column": "__floe_valid_from",
|
|
48
|
+
"valid_to_column": "__floe_valid_to",
|
|
49
|
+
}
|
|
50
|
+
# Same normalisation as `checks/cast.py`, so every accepted type casts.
|
|
51
|
+
_TYPE_NAMES = {
|
|
52
|
+
*("string", "str", "text", "boolean", "bool", "datetime", "timestamp", "date"),
|
|
53
|
+
*("number", "float", "float32", "float64", "double", "int"),
|
|
54
|
+
*("int8", "int16", "int32", "int64", "integer", "long"),
|
|
55
|
+
*("uint8", "uint16", "uint32", "uint64"),
|
|
56
|
+
} # fmt: skip
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _canonical(value: str) -> str:
|
|
60
|
+
# D7 (#584 S1, S2): no trim, unlike Rust's `normalize_value`; the run-time paths never trimmed.
|
|
61
|
+
return value.lower().replace("-", "").replace("_", "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fail(entity: EntityConfig, message: str) -> FloeConfigError:
|
|
65
|
+
return FloeConfigError(f"entity.name={entity.name} {message}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_primary_key(entity: EntityConfig) -> None:
|
|
69
|
+
primary_key = entity.schema_.primary_key
|
|
70
|
+
if primary_key is None:
|
|
71
|
+
return
|
|
72
|
+
if not primary_key:
|
|
73
|
+
raise _fail(entity, "schema.primary_key must not be empty")
|
|
74
|
+
columns = {c.name: c for c in reversed(entity.schema_.columns)}
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
for index, name in enumerate(primary_key):
|
|
77
|
+
value = name.strip()
|
|
78
|
+
if not value:
|
|
79
|
+
raise _fail(entity, f"schema.primary_key[{index}] must not be empty")
|
|
80
|
+
if value in seen:
|
|
81
|
+
raise _fail(entity, f"schema.primary_key has duplicate column {value}")
|
|
82
|
+
seen.add(value)
|
|
83
|
+
if value not in columns:
|
|
84
|
+
raise _fail(
|
|
85
|
+
entity,
|
|
86
|
+
f"schema.primary_key[{index}]={value} references unknown schema column",
|
|
87
|
+
)
|
|
88
|
+
if columns[value].nullable is True:
|
|
89
|
+
raise _fail(
|
|
90
|
+
entity, f"schema.primary_key column {value} cannot set nullable=true"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_unique_keys(entity: EntityConfig) -> None:
|
|
95
|
+
# ponytail: Rust's override and dedup warnings are not ported; they are not errors.
|
|
96
|
+
unique_keys = entity.schema_.unique_keys
|
|
97
|
+
if unique_keys is None:
|
|
98
|
+
return
|
|
99
|
+
if not unique_keys:
|
|
100
|
+
raise _fail(entity, "schema.unique_keys must not be empty")
|
|
101
|
+
names = {c.name for c in entity.schema_.columns}
|
|
102
|
+
for index, key in enumerate(unique_keys):
|
|
103
|
+
where = f"schema.unique_keys[{index}]"
|
|
104
|
+
if not key:
|
|
105
|
+
raise _fail(entity, f"{where} must not be empty")
|
|
106
|
+
seen: set[str] = set()
|
|
107
|
+
for column_index, name in enumerate(key):
|
|
108
|
+
value = name.strip()
|
|
109
|
+
if not value:
|
|
110
|
+
raise _fail(entity, f"{where}[{column_index}] must not be empty")
|
|
111
|
+
if value not in names:
|
|
112
|
+
raise _fail(
|
|
113
|
+
entity,
|
|
114
|
+
f"{where}[{column_index}]={value} references unknown schema column",
|
|
115
|
+
)
|
|
116
|
+
if value in seen:
|
|
117
|
+
raise _fail(entity, f"{where} has duplicate column {value}")
|
|
118
|
+
seen.add(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_evolution(entity: EntityConfig, version: str) -> None:
|
|
122
|
+
evolution = entity.schema_.schema_evolution
|
|
123
|
+
if evolution is None:
|
|
124
|
+
return
|
|
125
|
+
if config_version(version) < (0, 2):
|
|
126
|
+
raise _fail(entity, 'schema.schema_evolution requires root.version >= "0.2"')
|
|
127
|
+
if evolution.mode == "add_columns" and entity.sink.accepted.format != "iceberg":
|
|
128
|
+
raise _fail(
|
|
129
|
+
entity,
|
|
130
|
+
"schema.schema_evolution.mode=add_columns requires sink.accepted.format=iceberg",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _merge_list(entity: EntityConfig, field: str, values: list[str]) -> None:
|
|
135
|
+
names = {c.name for c in entity.schema_.columns}
|
|
136
|
+
primary_key = {c.strip() for c in entity.schema_.primary_key or []}
|
|
137
|
+
where = f"sink.accepted.merge.{field}"
|
|
138
|
+
seen: set[str] = set()
|
|
139
|
+
for index, raw in enumerate(values):
|
|
140
|
+
value = raw.strip()
|
|
141
|
+
if not value:
|
|
142
|
+
raise _fail(entity, f"{where}[{index}] must not be empty")
|
|
143
|
+
if value not in names:
|
|
144
|
+
raise _fail(
|
|
145
|
+
entity, f"{where}[{index}]={value} references unknown schema column"
|
|
146
|
+
)
|
|
147
|
+
if value in seen:
|
|
148
|
+
raise _fail(entity, f"{where} has duplicate column {value}")
|
|
149
|
+
seen.add(value)
|
|
150
|
+
for index, raw in enumerate(values):
|
|
151
|
+
if raw.strip() in primary_key:
|
|
152
|
+
raise _fail(
|
|
153
|
+
entity,
|
|
154
|
+
f"{where}[{index}]={raw.strip()} cannot reference schema.primary_key column",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_merge(entity: EntityConfig) -> None:
|
|
159
|
+
merge = entity.sink.accepted.merge
|
|
160
|
+
if merge is None:
|
|
161
|
+
return
|
|
162
|
+
mode = entity.sink.write_mode
|
|
163
|
+
if not any(
|
|
164
|
+
m.startswith("merge") for m in SINK_WRITE_MODES[entity.sink.accepted.format]
|
|
165
|
+
):
|
|
166
|
+
raise _fail(entity, "sink.accepted.merge requires sink.accepted.format=iceberg")
|
|
167
|
+
if mode not in ("merge_scd1", "merge_scd2"):
|
|
168
|
+
raise _fail(
|
|
169
|
+
entity,
|
|
170
|
+
"sink.accepted.merge is only supported with sink.write_mode=merge_scd1 or merge_scd2",
|
|
171
|
+
)
|
|
172
|
+
for field in ("ignore_columns", "compare_columns"):
|
|
173
|
+
values = getattr(merge, field)
|
|
174
|
+
if values is not None:
|
|
175
|
+
_merge_list(entity, field, values)
|
|
176
|
+
if merge.scd2 is None:
|
|
177
|
+
return
|
|
178
|
+
if mode != "merge_scd2":
|
|
179
|
+
raise _fail(
|
|
180
|
+
entity,
|
|
181
|
+
"sink.accepted.merge.scd2 is only supported with sink.write_mode=merge_scd2",
|
|
182
|
+
)
|
|
183
|
+
nc = (
|
|
184
|
+
entity.schema_.normalize_columns
|
|
185
|
+
) # raw strategy: an unsupported one is validate_schema's error
|
|
186
|
+
strategy = (nc.strategy or "snake_case") if nc and nc.enabled else None
|
|
187
|
+
names = set(output_names(entity.schema_.columns, strategy))
|
|
188
|
+
resolved = {
|
|
189
|
+
field: (default if (v := getattr(merge.scd2, field)) is None else v).strip()
|
|
190
|
+
for field, default in SCD2_DEFAULTS.items()
|
|
191
|
+
}
|
|
192
|
+
for field, value in resolved.items():
|
|
193
|
+
if not value:
|
|
194
|
+
raise _fail(entity, f"sink.accepted.merge.scd2.{field} must not be empty")
|
|
195
|
+
if value in names:
|
|
196
|
+
raise _fail(
|
|
197
|
+
entity,
|
|
198
|
+
f"sink.accepted.merge.scd2.{field}={value} collides with schema column name",
|
|
199
|
+
)
|
|
200
|
+
if len(set(resolved.values())) != len(resolved):
|
|
201
|
+
raise _fail(entity, "sink.accepted.merge.scd2 column names must be unique")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_write_mode(entity: EntityConfig) -> None:
|
|
205
|
+
mode, fmt = entity.sink.write_mode, entity.sink.accepted.format
|
|
206
|
+
if mode not in SINK_WRITE_MODES[fmt]:
|
|
207
|
+
raise _fail(
|
|
208
|
+
entity,
|
|
209
|
+
f"sink.write_mode={mode} is not supported by sink.accepted.format={fmt}",
|
|
210
|
+
)
|
|
211
|
+
if mode in ("merge_scd1", "merge_scd2"):
|
|
212
|
+
primary_key = entity.schema_.primary_key
|
|
213
|
+
if primary_key is None:
|
|
214
|
+
raise _fail(entity, f"sink.write_mode={mode} requires schema.primary_key")
|
|
215
|
+
if not primary_key:
|
|
216
|
+
raise _fail(
|
|
217
|
+
entity, f"sink.write_mode={mode} requires non-empty schema.primary_key"
|
|
218
|
+
)
|
|
219
|
+
_check_merge(entity)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _check_partitioning(entity: EntityConfig) -> None:
|
|
223
|
+
accepted = entity.sink.accepted
|
|
224
|
+
if accepted.partition_by is not None and accepted.partition_spec is not None:
|
|
225
|
+
raise _fail(
|
|
226
|
+
entity,
|
|
227
|
+
"sink.accepted.partition_by and sink.accepted.partition_spec are mutually exclusive",
|
|
228
|
+
)
|
|
229
|
+
# ponytail: `partition_by` is Delta-only (D1), so its per-column checks are unreachable.
|
|
230
|
+
if accepted.partition_by is not None:
|
|
231
|
+
raise _fail(
|
|
232
|
+
entity,
|
|
233
|
+
"sink.accepted.partition_by is not supported in 0.8; "
|
|
234
|
+
"use sink.accepted.partition_spec with sink.accepted.format=iceberg",
|
|
235
|
+
)
|
|
236
|
+
spec = accepted.partition_spec
|
|
237
|
+
if spec is None:
|
|
238
|
+
return
|
|
239
|
+
where = "sink.accepted.partition_spec"
|
|
240
|
+
if accepted.format != "iceberg":
|
|
241
|
+
raise _fail(
|
|
242
|
+
entity, f"{where} is only supported for sink.accepted.format=iceberg"
|
|
243
|
+
)
|
|
244
|
+
if not spec:
|
|
245
|
+
raise _fail(entity, f"{where} must not be empty")
|
|
246
|
+
names = {c.name for c in entity.schema_.columns}
|
|
247
|
+
seen: set[tuple[str, str]] = set()
|
|
248
|
+
for index, field in enumerate(spec):
|
|
249
|
+
column = field.column.strip()
|
|
250
|
+
if not column:
|
|
251
|
+
raise _fail(entity, f"{where}[{index}].column must not be empty")
|
|
252
|
+
if column not in names:
|
|
253
|
+
raise _fail(
|
|
254
|
+
entity,
|
|
255
|
+
f"{where}[{index}].column={column} references unknown schema column",
|
|
256
|
+
)
|
|
257
|
+
transform = field.transform.strip().lower()
|
|
258
|
+
if transform not in PARTITION_TRANSFORMS:
|
|
259
|
+
raise _fail(
|
|
260
|
+
entity,
|
|
261
|
+
f"{where}[{index}].transform={field.transform} is unsupported "
|
|
262
|
+
f"(allowed: {', '.join(PARTITION_TRANSFORMS)})",
|
|
263
|
+
)
|
|
264
|
+
if transform == "bucket" and field.num_buckets is None:
|
|
265
|
+
raise _fail(
|
|
266
|
+
entity, f"{where}[{index}].num_buckets is required for transform=bucket"
|
|
267
|
+
)
|
|
268
|
+
if transform != "bucket" and field.num_buckets is not None:
|
|
269
|
+
raise _fail(
|
|
270
|
+
entity,
|
|
271
|
+
f"{where}[{index}].num_buckets is only supported with transform=bucket",
|
|
272
|
+
)
|
|
273
|
+
if field.num_buckets == 0:
|
|
274
|
+
raise _fail(entity, f"{where}[{index}].num_buckets must be greater than 0")
|
|
275
|
+
if (column, transform) in seen:
|
|
276
|
+
raise _fail(entity, f"{where} has duplicate entry ({column}, {transform})")
|
|
277
|
+
seen.add((column, transform))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _storage_ref(
|
|
281
|
+
config: RootConfig,
|
|
282
|
+
name: str | None,
|
|
283
|
+
field: str,
|
|
284
|
+
who: str,
|
|
285
|
+
) -> str:
|
|
286
|
+
"""`resolve_name` then `validate_reference`; `who` is the `entity.name=X ` prefix, empty for the report."""
|
|
287
|
+
storages = config.storages
|
|
288
|
+
if name is None:
|
|
289
|
+
name = (storages and storages.default) or "local"
|
|
290
|
+
if storages is None and name != "local":
|
|
291
|
+
raise FloeConfigError(
|
|
292
|
+
f"{who}{field} references unknown storage {name} (no storages block)"
|
|
293
|
+
)
|
|
294
|
+
if storages and name not in {d.name for d in storages.definitions}:
|
|
295
|
+
raise FloeConfigError(f"{who}{field} references unknown storage {name}")
|
|
296
|
+
return name
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _storage_type(config: RootConfig, name: str) -> str:
|
|
300
|
+
definitions = config.storages.definitions if config.storages else []
|
|
301
|
+
return next((d.type for d in definitions if d.name == name), "local")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _check_iceberg(
|
|
305
|
+
entity: EntityConfig, config: RootConfig, accepted_storage: str
|
|
306
|
+
) -> None:
|
|
307
|
+
accepted = entity.sink.accepted
|
|
308
|
+
iceberg = accepted.iceberg
|
|
309
|
+
if iceberg is None:
|
|
310
|
+
return
|
|
311
|
+
if accepted.format != "iceberg":
|
|
312
|
+
raise _fail(
|
|
313
|
+
entity,
|
|
314
|
+
"sink.accepted.iceberg is only supported for sink.accepted.format=iceberg",
|
|
315
|
+
)
|
|
316
|
+
catalogs = config.catalogs
|
|
317
|
+
name = (
|
|
318
|
+
iceberg.catalog
|
|
319
|
+
if iceberg.catalog is not None
|
|
320
|
+
else catalogs and catalogs.default
|
|
321
|
+
)
|
|
322
|
+
if name is None:
|
|
323
|
+
raise _fail(
|
|
324
|
+
entity,
|
|
325
|
+
"sink.accepted.iceberg.catalog is required (or set catalogs.default)",
|
|
326
|
+
)
|
|
327
|
+
definitions = catalogs.definitions if catalogs else []
|
|
328
|
+
definition = next((c for c in definitions if c.name == name), None)
|
|
329
|
+
if definition is None:
|
|
330
|
+
raise _fail(
|
|
331
|
+
entity, f"sink.accepted.iceberg.catalog references unknown catalog {name}"
|
|
332
|
+
)
|
|
333
|
+
# Unity and Glue are rejected at parse (D1); the registry checked `warehouse_storage`.
|
|
334
|
+
if definition.warehouse_storage is None:
|
|
335
|
+
kind = _storage_type(config, accepted_storage)
|
|
336
|
+
if kind not in ("s3", "gcs"):
|
|
337
|
+
raise _fail(
|
|
338
|
+
entity,
|
|
339
|
+
f"sink.accepted.iceberg.catalog requires sink.accepted storage type s3 or gcs (got {kind})",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _check_sink(entity: EntityConfig, config: RootConfig) -> None:
|
|
344
|
+
sink, accepted = entity.sink, entity.sink.accepted
|
|
345
|
+
if accepted.format in ("delta", "duckdb"): # D1 (#619): reported first
|
|
346
|
+
raise _fail(
|
|
347
|
+
entity, f"sink.accepted.format={accepted.format} is not supported in 0.8"
|
|
348
|
+
)
|
|
349
|
+
if accepted.format not in SINK_FORMATS:
|
|
350
|
+
raise _fail(entity, f"sink.accepted.format={accepted.format} is unsupported")
|
|
351
|
+
options = accepted.options
|
|
352
|
+
if options is not None and accepted.format == "parquet":
|
|
353
|
+
if options.compression not in (None, *PARQUET_COMPRESSIONS):
|
|
354
|
+
raise _fail(
|
|
355
|
+
entity,
|
|
356
|
+
f"sink.accepted.options.compression={options.compression} is unsupported "
|
|
357
|
+
f"(allowed: {', '.join(PARQUET_COMPRESSIONS)})",
|
|
358
|
+
)
|
|
359
|
+
for field in ("row_group_size", "max_size_per_file"):
|
|
360
|
+
if getattr(options, field) == 0:
|
|
361
|
+
raise _fail(
|
|
362
|
+
entity, f"sink.accepted.options.{field} must be greater than 0"
|
|
363
|
+
)
|
|
364
|
+
# D39 (#1007): abort writes the failing file to sink.rejected too; Rust 0.7 only failed at run time
|
|
365
|
+
if entity.policy.severity in ("reject", "abort") and sink.rejected is None:
|
|
366
|
+
raise _fail(entity, f"sink.rejected is required when policy.severity={entity.policy.severity}") # fmt: skip
|
|
367
|
+
if sink.rejected is not None and sink.rejected.format != "csv":
|
|
368
|
+
raise _fail(
|
|
369
|
+
entity, f"sink.rejected.format={sink.rejected.format} is unsupported"
|
|
370
|
+
)
|
|
371
|
+
who = f"entity.name={entity.name} "
|
|
372
|
+
accepted_storage = _storage_ref(
|
|
373
|
+
config, accepted.storage, "sink.accepted.storage", who
|
|
374
|
+
)
|
|
375
|
+
_check_iceberg(entity, config, accepted_storage)
|
|
376
|
+
_check_partitioning(entity)
|
|
377
|
+
_check_write_mode(entity)
|
|
378
|
+
if sink.rejected is not None:
|
|
379
|
+
_storage_ref(config, sink.rejected.storage, "sink.rejected.storage", who)
|
|
380
|
+
archive = sink.archive
|
|
381
|
+
source = _storage_ref(config, entity.source.storage, "source.storage", who)
|
|
382
|
+
if archive is not None and archive.storage not in (None, source):
|
|
383
|
+
raise _fail(
|
|
384
|
+
entity, f"sink.archive.storage must match source.storage ({source})"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def validate_schema(entity: EntityConfig, version: str) -> None:
|
|
389
|
+
schema = entity.schema_
|
|
390
|
+
part = entity.source.partitioning # D28: derived columns are not read from the file
|
|
391
|
+
derived = {part.date_column, part.batch_column} if part else set[str | None]()
|
|
392
|
+
inputs = [c for c in schema.columns if c.name not in derived]
|
|
393
|
+
if entity.source.format == "json" and len(inputs) > MAX_JSON_COLUMNS:
|
|
394
|
+
raise _fail(
|
|
395
|
+
entity,
|
|
396
|
+
f"schema.columns has {len(inputs)} entries which exceeds "
|
|
397
|
+
f"the JSON selector limit of {MAX_JSON_COLUMNS}",
|
|
398
|
+
)
|
|
399
|
+
strategy = schema.normalize_columns and schema.normalize_columns.strategy
|
|
400
|
+
if strategy is not None and _canonical(strategy) not in {
|
|
401
|
+
_canonical(s) for s in NORMALIZE_STRATEGIES
|
|
402
|
+
}:
|
|
403
|
+
raise _fail(
|
|
404
|
+
entity,
|
|
405
|
+
f"schema.normalize_columns.strategy={strategy} is unsupported "
|
|
406
|
+
f"(allowed: {', '.join(NORMALIZE_STRATEGIES)})",
|
|
407
|
+
)
|
|
408
|
+
mismatch = schema.mismatch
|
|
409
|
+
if mismatch is not None:
|
|
410
|
+
for field, policy, allowed in (
|
|
411
|
+
("missing_columns", mismatch.missing_columns, MISMATCH_MISSING),
|
|
412
|
+
("extra_columns", mismatch.extra_columns, MISMATCH_EXTRA),
|
|
413
|
+
):
|
|
414
|
+
if policy is not None and policy not in allowed:
|
|
415
|
+
raise _fail(
|
|
416
|
+
entity,
|
|
417
|
+
f"schema.mismatch.{field}={policy} is unsupported "
|
|
418
|
+
f"(allowed: {', '.join(allowed)})",
|
|
419
|
+
)
|
|
420
|
+
for index, column in enumerate(inputs):
|
|
421
|
+
kind = _canonical(column.column_type)
|
|
422
|
+
try:
|
|
423
|
+
exact = decimal_type(column.column_type)
|
|
424
|
+
except ValueError as err:
|
|
425
|
+
raise _fail(entity, f"schema.columns[{index}].type: {err}") from err
|
|
426
|
+
if kind == "decimal": # Float64 cannot hold exact decimals; scale is required
|
|
427
|
+
raise _fail(
|
|
428
|
+
entity,
|
|
429
|
+
f"schema.columns[{index}].type=decimal needs a precision and scale, "
|
|
430
|
+
"e.g. decimal(19,2); use float64 for approximate numbers",
|
|
431
|
+
)
|
|
432
|
+
if exact is None and kind not in _TYPE_NAMES:
|
|
433
|
+
raise _fail(
|
|
434
|
+
entity,
|
|
435
|
+
f"schema.columns[{index}].type={column.column_type} is unsupported "
|
|
436
|
+
f"for column {column.name} (allowed: {', '.join(COLUMN_TYPES)})",
|
|
437
|
+
)
|
|
438
|
+
parse = {"xml": parse_selector, "json": parse_json_selector}.get(
|
|
439
|
+
entity.source.format
|
|
440
|
+
)
|
|
441
|
+
if parse:
|
|
442
|
+
selector = column.source if column.source is not None else column.name
|
|
443
|
+
try:
|
|
444
|
+
parse(selector)
|
|
445
|
+
except ValueError as err:
|
|
446
|
+
raise _fail(
|
|
447
|
+
entity,
|
|
448
|
+
f"schema.columns[{index}].source={selector} is invalid: {err}",
|
|
449
|
+
) from err
|
|
450
|
+
_check_primary_key(entity)
|
|
451
|
+
_check_unique_keys(entity)
|
|
452
|
+
_check_evolution(entity, version)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _check_source(entity: EntityConfig, config: RootConfig) -> None:
|
|
456
|
+
source = entity.source
|
|
457
|
+
if source.format == "fixed": # D1 (#619): fixed-width is dropped
|
|
458
|
+
raise _fail(entity, f"source.format={source.format} is not supported in 0.8")
|
|
459
|
+
if source.format not in SOURCE_FORMATS:
|
|
460
|
+
raise _fail(entity, f"source.format={source.format} is unsupported")
|
|
461
|
+
if source.cast_mode not in CAST_MODES:
|
|
462
|
+
raise _fail(
|
|
463
|
+
entity,
|
|
464
|
+
f"source.cast_mode={source.cast_mode} is unsupported "
|
|
465
|
+
f"(allowed: {', '.join(CAST_MODES)})",
|
|
466
|
+
)
|
|
467
|
+
who = f"entity.name={entity.name} "
|
|
468
|
+
_storage_ref(config, source.storage, "source.storage", who)
|
|
469
|
+
options = source.options
|
|
470
|
+
mode = options.json_mode or "array"
|
|
471
|
+
if source.format == "json" and mode not in ("array", "ndjson"):
|
|
472
|
+
raise _fail(
|
|
473
|
+
entity,
|
|
474
|
+
f"source.options.json_mode={mode} is unsupported (allowed: array, ndjson)",
|
|
475
|
+
)
|
|
476
|
+
if source.format == "xlsx":
|
|
477
|
+
sheet, header_row = options.sheet, options.header_row
|
|
478
|
+
if sheet is not None and not sheet.strip():
|
|
479
|
+
raise _fail(entity, "source.options.sheet must not be empty")
|
|
480
|
+
header_row = 1 if header_row is None else header_row
|
|
481
|
+
if header_row == 0:
|
|
482
|
+
raise _fail(entity, "source.options.header_row must be greater than 0")
|
|
483
|
+
data_row = header_row + 1 if options.data_row is None else options.data_row
|
|
484
|
+
if data_row <= header_row:
|
|
485
|
+
raise _fail(
|
|
486
|
+
entity,
|
|
487
|
+
"source.options.data_row must be greater than source.options.header_row",
|
|
488
|
+
)
|
|
489
|
+
if source.format == "xml":
|
|
490
|
+
if not (options.row_tag or "").strip():
|
|
491
|
+
raise _fail(entity, "source.options.row_tag is required for xml input")
|
|
492
|
+
for field in ("namespace", "value_tag"):
|
|
493
|
+
value = getattr(options, field)
|
|
494
|
+
if value is not None and not value.strip():
|
|
495
|
+
raise _fail(entity, f"source.options.{field} must not be empty")
|
|
496
|
+
# D7 (#584 S3, S4): Rust 0.7 only rejects these when the reader opens the file.
|
|
497
|
+
if source.format in ("csv", "tsv"):
|
|
498
|
+
separator, encoding = options.separator or "", options.encoding or "utf8"
|
|
499
|
+
if len(separator.encode()) != 1:
|
|
500
|
+
raise _fail(
|
|
501
|
+
entity, f"separator must be a single byte, got {rust_debug(separator)}"
|
|
502
|
+
)
|
|
503
|
+
if _canonical(encoding) not in ("utf8", "lossyutf8"):
|
|
504
|
+
raise _fail(entity, f"unsupported encoding: {encoding}")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _check_source_partitioning(entity: EntityConfig) -> None:
|
|
508
|
+
p = entity.source.partitioning
|
|
509
|
+
if p is None:
|
|
510
|
+
return
|
|
511
|
+
where, pattern = "source.partitioning", f"source.partitioning.pattern={p.pattern}"
|
|
512
|
+
tokens = PARTITION_TOKEN.findall(p.pattern)
|
|
513
|
+
if unknown := [t for t in tokens if t not in PARTITION_TOKENS]:
|
|
514
|
+
allowed = ", ".join(f"{{{t}}}" for t in PARTITION_TOKENS)
|
|
515
|
+
raise _fail(entity, f"{pattern} has unknown token {{{unknown[0]}}} (allowed: {allowed})") # fmt: skip
|
|
516
|
+
if set("{}") & set(PARTITION_TOKEN.sub("", p.pattern)):
|
|
517
|
+
raise _fail(entity, f"{pattern} has an unmatched brace")
|
|
518
|
+
if repeated := [t for t in PARTITION_TOKENS if tokens.count(t) > 1]:
|
|
519
|
+
raise _fail(entity, f"{pattern} repeats token {{{repeated[0]}}}")
|
|
520
|
+
if not {"yyyy", "MM", "dd"} <= set(tokens):
|
|
521
|
+
raise _fail(entity, f"{pattern} must contain {{yyyy}}, {{MM}} and {{dd}}")
|
|
522
|
+
# #913: also the names after normalize_columns (the input names and the output names)
|
|
523
|
+
nc, columns = entity.schema_.normalize_columns, entity.schema_.columns
|
|
524
|
+
strategy = (nc.strategy or "snake_case") if nc and nc.enabled else None
|
|
525
|
+
names = {c.name for c in columns} | set(output_names(columns, strategy))
|
|
526
|
+
inputs = {normalize_name(c.source or c.name, strategy) for c in columns}
|
|
527
|
+
for field, name in (("date_column", p.date_column), ("batch_column", p.batch_column)): # fmt: skip
|
|
528
|
+
if name is None:
|
|
529
|
+
continue
|
|
530
|
+
if not name.strip():
|
|
531
|
+
raise _fail(entity, f"{where}.{field} must not be empty")
|
|
532
|
+
if name in names or normalize_name(name, strategy) in inputs:
|
|
533
|
+
raise _fail(entity, f"{where}.{field}={name} is already a column in schema.columns") # fmt: skip
|
|
534
|
+
if p.batch_column is not None and "batch" not in tokens:
|
|
535
|
+
raise _fail(entity, f"{where}.batch_column requires {{batch}} in {where}.pattern") # fmt: skip
|
|
536
|
+
if p.batch_column == p.date_column:
|
|
537
|
+
raise _fail(entity, f"{where}.batch_column={p.batch_column} must differ from {where}.date_column") # fmt: skip
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _check_state(entity: EntityConfig) -> None:
|
|
541
|
+
state, archive = entity.state, entity.sink.archive
|
|
542
|
+
if state and state.path is not None and not state.path.strip():
|
|
543
|
+
raise _fail(entity, "entity.state.path must not be empty")
|
|
544
|
+
mode = entity.incremental_mode
|
|
545
|
+
if mode == "archive" and archive is None:
|
|
546
|
+
raise _fail(entity, "incremental_mode=archive requires sink.archive")
|
|
547
|
+
# ponytail: Rust's deprecation warning for `sink.archive` without a mode is not ported.
|
|
548
|
+
if archive is not None and mode in ("file", "row"):
|
|
549
|
+
raise _fail(
|
|
550
|
+
entity,
|
|
551
|
+
f"sink.archive conflicts with incremental_mode={mode}; "
|
|
552
|
+
"use incremental_mode=archive or remove sink.archive",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _pii_error(entity: EntityConfig, name: str, message: str) -> FloeConfigError:
|
|
557
|
+
return _fail(entity, f"pii.columns[name={name}]{message}")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _check_pii(entity: EntityConfig) -> None:
|
|
561
|
+
pii = entity.pii
|
|
562
|
+
if pii is None:
|
|
563
|
+
return
|
|
564
|
+
if entity.policy.severity == "abort":
|
|
565
|
+
raise _fail(
|
|
566
|
+
entity,
|
|
567
|
+
"pii: masking is not applied when policy.severity=abort because the raw "
|
|
568
|
+
"file is written to sink.rejected without DataFrame processing",
|
|
569
|
+
)
|
|
570
|
+
if entity.sink.archive is not None:
|
|
571
|
+
raise _fail(
|
|
572
|
+
entity,
|
|
573
|
+
"pii: sink.archive copies the original unmasked source file to the archive "
|
|
574
|
+
"sink; remove sink.archive or disable pii masking",
|
|
575
|
+
)
|
|
576
|
+
schema = entity.schema_
|
|
577
|
+
mismatch = schema.mismatch
|
|
578
|
+
for field, policy, fix in (
|
|
579
|
+
("missing_columns", mismatch and mismatch.missing_columns, "fill_nulls"),
|
|
580
|
+
("extra_columns", mismatch and mismatch.extra_columns, "ignore"),
|
|
581
|
+
):
|
|
582
|
+
if policy == "reject_file":
|
|
583
|
+
raise _fail(
|
|
584
|
+
entity,
|
|
585
|
+
f"pii: schema.mismatch.{field}=reject_file writes the raw unmasked file "
|
|
586
|
+
"to sink.rejected before PII masking can be applied; "
|
|
587
|
+
f"use {field}={fix} instead",
|
|
588
|
+
)
|
|
589
|
+
columns = {c.name: c for c in schema.columns}
|
|
590
|
+
primary_key = set(schema.primary_key or [])
|
|
591
|
+
mode = entity.sink.write_mode
|
|
592
|
+
unique = primary_key | {c for key in schema.unique_keys or [] for c in key}
|
|
593
|
+
if (
|
|
594
|
+
schema.unique_keys is None
|
|
595
|
+
): # legacy `unique: true` is ignored once `unique_keys` is set
|
|
596
|
+
unique |= {c.name for c in schema.columns if c.unique is True}
|
|
597
|
+
seen: set[str] = set()
|
|
598
|
+
for col in pii.columns:
|
|
599
|
+
name, strategy = col.name, col.strategy
|
|
600
|
+
if name in seen:
|
|
601
|
+
raise _fail(entity, f"pii.columns: column name {name} is duplicated")
|
|
602
|
+
seen.add(name)
|
|
603
|
+
if name not in columns:
|
|
604
|
+
raise _fail(entity, f"pii.columns: {name} is not a schema column")
|
|
605
|
+
if strategy == "tokenize":
|
|
606
|
+
raise _pii_error(entity, name, ".strategy=tokenize is not yet supported")
|
|
607
|
+
declared = columns[name]
|
|
608
|
+
if strategy in ("hash", "redact", "mask") and _canonical(
|
|
609
|
+
declared.column_type
|
|
610
|
+
) not in ("string", "str", "text"):
|
|
611
|
+
raise _pii_error(
|
|
612
|
+
entity,
|
|
613
|
+
name,
|
|
614
|
+
f".strategy={strategy} can only be applied to string columns "
|
|
615
|
+
f"(declared type={declared.column_type}); "
|
|
616
|
+
"use strategy=nullify or strategy=drop instead",
|
|
617
|
+
)
|
|
618
|
+
if strategy == "nullify" and declared.nullable is False:
|
|
619
|
+
raise _pii_error(
|
|
620
|
+
entity,
|
|
621
|
+
name,
|
|
622
|
+
".strategy=nullify cannot be applied to a nullable=false column",
|
|
623
|
+
)
|
|
624
|
+
if strategy == "drop" and name in primary_key:
|
|
625
|
+
raise _pii_error(
|
|
626
|
+
entity, name, ".strategy=drop cannot be applied to a primary_key column"
|
|
627
|
+
)
|
|
628
|
+
if name in primary_key and mode in ("merge_scd1", "merge_scd2"):
|
|
629
|
+
raise _pii_error(
|
|
630
|
+
entity,
|
|
631
|
+
name,
|
|
632
|
+
f": PII strategy cannot be applied to a primary_key column with write_mode={mode}: "
|
|
633
|
+
"masking the merge key corrupts the merge predicate",
|
|
634
|
+
)
|
|
635
|
+
if name in unique and strategy in ("drop", "nullify", "redact", "mask"):
|
|
636
|
+
raise _pii_error(
|
|
637
|
+
entity,
|
|
638
|
+
name,
|
|
639
|
+
f".strategy={strategy} cannot be applied to a unique-key column: the strategy "
|
|
640
|
+
"collapses or removes values, violating uniqueness constraints",
|
|
641
|
+
)
|
|
642
|
+
if name in unique and strategy == "hash":
|
|
643
|
+
raise _pii_error(
|
|
644
|
+
entity,
|
|
645
|
+
name,
|
|
646
|
+
".strategy=hash cannot be applied to a unique-key column: uniqueness checks "
|
|
647
|
+
"record raw values in the run report before masking, leaking unmasked PII",
|
|
648
|
+
)
|
|
649
|
+
# ponytail: Rust's warning for an unkeyed `hash` is not ported.
|
|
650
|
+
fmt = entity.sink.accepted.format
|
|
651
|
+
if strategy == "drop" and fmt in ("iceberg", "delta"):
|
|
652
|
+
raise _pii_error(
|
|
653
|
+
entity,
|
|
654
|
+
name,
|
|
655
|
+
f".strategy=drop is not supported with format={fmt} "
|
|
656
|
+
"because the sink enforces the full declared schema",
|
|
657
|
+
)
|
|
658
|
+
if strategy == "mask":
|
|
659
|
+
pattern = col.mask_pattern
|
|
660
|
+
if pattern is None:
|
|
661
|
+
raise _pii_error(entity, name, ".strategy=mask requires mask_pattern")
|
|
662
|
+
if not (token_n(pattern, "{last") or token_n(pattern, "{first")):
|
|
663
|
+
raise _pii_error(
|
|
664
|
+
entity,
|
|
665
|
+
name,
|
|
666
|
+
".mask_pattern must contain at least one token ({firstN} or {lastN})",
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _check_storages(config: RootConfig) -> None:
|
|
671
|
+
storages, catalogs = config.storages, config.catalogs
|
|
672
|
+
names: set[str] = set()
|
|
673
|
+
if storages is not None:
|
|
674
|
+
if not storages.definitions:
|
|
675
|
+
raise FloeConfigError("storages.definitions must not be empty")
|
|
676
|
+
for d in storages.definitions:
|
|
677
|
+
if d.type not in STORAGE_TYPES:
|
|
678
|
+
raise FloeConfigError(
|
|
679
|
+
f"storages.definitions name={d.name} type={d.type} is unsupported "
|
|
680
|
+
f"(allowed: {', '.join(STORAGE_TYPES)})"
|
|
681
|
+
)
|
|
682
|
+
if d.type == "s3" and (d.bucket is None or d.region is None):
|
|
683
|
+
missing = "bucket" if d.bucket is None else "region"
|
|
684
|
+
raise FloeConfigError(
|
|
685
|
+
f"storages.definitions name={d.name} requires {missing} for type s3"
|
|
686
|
+
)
|
|
687
|
+
if d.name in names:
|
|
688
|
+
raise FloeConfigError(
|
|
689
|
+
f"storages.definitions name={d.name} is duplicated"
|
|
690
|
+
)
|
|
691
|
+
names.add(d.name)
|
|
692
|
+
if storages.default is None:
|
|
693
|
+
raise FloeConfigError("storages.default is required when storages is set")
|
|
694
|
+
if storages.default not in names:
|
|
695
|
+
raise FloeConfigError(
|
|
696
|
+
f"storages.default={storages.default} does not match any definition"
|
|
697
|
+
)
|
|
698
|
+
if catalogs is None:
|
|
699
|
+
return
|
|
700
|
+
if not catalogs.definitions:
|
|
701
|
+
raise FloeConfigError("catalogs.definitions must not be empty")
|
|
702
|
+
seen: set[str] = set()
|
|
703
|
+
for c in catalogs.definitions:
|
|
704
|
+
wh = c.warehouse_storage
|
|
705
|
+
if wh is not None:
|
|
706
|
+
if wh not in names:
|
|
707
|
+
raise FloeConfigError(
|
|
708
|
+
f"catalogs.definitions name={c.name} warehouse_storage "
|
|
709
|
+
f"references unknown storage {wh}"
|
|
710
|
+
)
|
|
711
|
+
if (kind := _storage_type(config, wh)) not in ("s3", "gcs"):
|
|
712
|
+
raise FloeConfigError(
|
|
713
|
+
f"catalogs.definitions name={c.name} warehouse_storage must "
|
|
714
|
+
f"reference s3 or gcs storage for rest catalog (got {kind})"
|
|
715
|
+
)
|
|
716
|
+
if c.name in seen:
|
|
717
|
+
raise FloeConfigError(f"catalogs.definitions name={c.name} is duplicated")
|
|
718
|
+
seen.add(c.name)
|
|
719
|
+
if catalogs.default is not None and catalogs.default not in seen:
|
|
720
|
+
raise FloeConfigError(
|
|
721
|
+
f"catalogs.default={catalogs.default} does not match any definition"
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def check_lineage(lineage: LineageConfig, ctx: str) -> None:
|
|
726
|
+
if not lineage.url.strip():
|
|
727
|
+
raise FloeConfigError(f"{ctx}.url must not be empty")
|
|
728
|
+
if not lineage.namespace.strip():
|
|
729
|
+
raise FloeConfigError(f"{ctx}.namespace must not be empty")
|
|
730
|
+
if lineage.max_failures == 0:
|
|
731
|
+
raise FloeConfigError(f"{ctx}.max_failures must be at least 1")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _check_report_lineage(config: RootConfig) -> None:
|
|
735
|
+
report, lineage = config.report, config.lineage
|
|
736
|
+
name = _storage_ref(config, report.storage, "report.storage", "")
|
|
737
|
+
if _storage_type(config, name) == "local" and report.path.startswith(REMOTE_URIS):
|
|
738
|
+
raise FloeConfigError(f"report.path must be a local path (got {report.path})")
|
|
739
|
+
# D7 (#584 S5): Rust 0.7 only rejects an unknown formatter when a run starts.
|
|
740
|
+
if report.formatter not in (None, "json", "csv", "text"):
|
|
741
|
+
raise FloeConfigError(f"unsupported report.formatter: {report.formatter}")
|
|
742
|
+
if lineage is not None:
|
|
743
|
+
check_lineage(lineage, "lineage")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def validate_entities(config: RootConfig) -> None:
|
|
747
|
+
version = config_version(config.version)
|
|
748
|
+
if version < MIN_VERSION:
|
|
749
|
+
raise FloeConfigError(
|
|
750
|
+
f"root.version={config.version} is unsupported "
|
|
751
|
+
f"(minimum supported version: {MIN_VERSION[0]}.{MIN_VERSION[1]})"
|
|
752
|
+
)
|
|
753
|
+
if version > FLOE_VERSION:
|
|
754
|
+
raise FloeConfigError(
|
|
755
|
+
f"root.version={config.version} is unsupported "
|
|
756
|
+
f"(newer than Floe {FLOE_VERSION[0]}.{FLOE_VERSION[1]})"
|
|
757
|
+
)
|
|
758
|
+
if not config.entities:
|
|
759
|
+
raise FloeConfigError(
|
|
760
|
+
"entities list is empty (at least one entity is required)"
|
|
761
|
+
)
|
|
762
|
+
_check_storages(config)
|
|
763
|
+
_check_report_lineage(config)
|
|
764
|
+
ids: dict[str, int] = {}
|
|
765
|
+
for index, entity in enumerate(config.entities):
|
|
766
|
+
_check_source(entity, config)
|
|
767
|
+
_check_source_partitioning(entity)
|
|
768
|
+
entity = with_partition_columns(entity)
|
|
769
|
+
_check_state(entity)
|
|
770
|
+
_check_sink(entity, config)
|
|
771
|
+
validate_schema(entity, config.version)
|
|
772
|
+
_check_pii(entity)
|
|
773
|
+
for field, value in (("name", entity.name), ("domain", entity.domain)):
|
|
774
|
+
if (
|
|
775
|
+
version >= QUALIFIED_VERSION
|
|
776
|
+
and value is not None
|
|
777
|
+
and (not value or any(c in value for c in "./\\"))
|
|
778
|
+
):
|
|
779
|
+
raise _fail(
|
|
780
|
+
entity,
|
|
781
|
+
f"entity.{field} must be non-empty without '.', '/' or '\\' "
|
|
782
|
+
'when root.version >= "0.3"',
|
|
783
|
+
)
|
|
784
|
+
if entity.id in ids:
|
|
785
|
+
raise FloeConfigError(
|
|
786
|
+
f"entity.name={entity.id} is duplicated in config "
|
|
787
|
+
f"(entities[{ids[entity.id]}] and entities[{index}])"
|
|
788
|
+
)
|
|
789
|
+
ids[entity.id] = index
|
|
790
|
+
# D26 (#844): Rust 0.7's catalog-less Iceberg tables are dropped; checked last so
|
|
791
|
+
# configs Rust rejects keep its message. `_check_iceberg` resolved the catalog.
|
|
792
|
+
for entity in config.entities:
|
|
793
|
+
if entity.sink.accepted.format == "iceberg" and entity.sink.accepted.iceberg is None: # fmt: skip
|
|
794
|
+
raise _fail(
|
|
795
|
+
entity,
|
|
796
|
+
"sink.accepted.format=iceberg requires sink.accepted.iceberg with a catalog in 0.8",
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def validate_config(config: RootConfig) -> None:
|
|
801
|
+
"""Rust `config::validate_config`."""
|
|
802
|
+
validate_domains(config)
|
|
803
|
+
validate_entities(config)
|