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/entity.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Entity parsing, ported from `config/parse.rs` (`parse_entity` and the entity-level blocks)."""
|
|
2
|
+
|
|
3
|
+
import string
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeVar, get_args
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from floe.config.types import (
|
|
10
|
+
ArchiveTarget,
|
|
11
|
+
ColumnConfig,
|
|
12
|
+
EntityConfig,
|
|
13
|
+
EntityMetadata,
|
|
14
|
+
EntityStateConfig,
|
|
15
|
+
IcebergPartitionField,
|
|
16
|
+
IcebergSinkOptions,
|
|
17
|
+
IncrementalMode,
|
|
18
|
+
MergeOptions,
|
|
19
|
+
MergeScd2Options,
|
|
20
|
+
NormalizeColumnsConfig,
|
|
21
|
+
PiiConfig,
|
|
22
|
+
PolicyConfig,
|
|
23
|
+
PolicySeverity,
|
|
24
|
+
SchemaConfig,
|
|
25
|
+
SchemaEvolutionConfig,
|
|
26
|
+
SchemaEvolutionIncompatibleAction,
|
|
27
|
+
SchemaEvolutionMode,
|
|
28
|
+
SchemaMismatchConfig,
|
|
29
|
+
SinkConfig,
|
|
30
|
+
SinkOptions,
|
|
31
|
+
SinkTarget,
|
|
32
|
+
SourceConfig,
|
|
33
|
+
SourceOptions,
|
|
34
|
+
SourcePartitioning,
|
|
35
|
+
WriteMode,
|
|
36
|
+
)
|
|
37
|
+
from floe.config.yaml import (
|
|
38
|
+
get_array,
|
|
39
|
+
get_string,
|
|
40
|
+
get_value,
|
|
41
|
+
opt_bool,
|
|
42
|
+
opt_string,
|
|
43
|
+
opt_u32,
|
|
44
|
+
opt_u64,
|
|
45
|
+
opt_vec_string,
|
|
46
|
+
opt_vec_vec_string,
|
|
47
|
+
rust_debug,
|
|
48
|
+
validate_known_keys,
|
|
49
|
+
yaml_array,
|
|
50
|
+
yaml_hash,
|
|
51
|
+
)
|
|
52
|
+
from floe.errors import FloeConfigError, FloeError
|
|
53
|
+
from floe.pii import PiiColumn, Strategy
|
|
54
|
+
|
|
55
|
+
_T = TypeVar("_T", bound=str)
|
|
56
|
+
_R = TypeVar("_R")
|
|
57
|
+
_M = TypeVar("_M", bound=BaseModel)
|
|
58
|
+
_ASCII_LOWER = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
|
|
59
|
+
# Rust's `trim()` strips Unicode White_Space only; `str.strip()` also strips U+001C to U+001F
|
|
60
|
+
WHITE_SPACE = "\t\n\v\f\r \x85\xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _or(value: _R | None, default: _R) -> _R:
|
|
64
|
+
return default if value is None else value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pick(value: str, allowed: tuple[_T, ...]) -> _T | None:
|
|
68
|
+
return next((option for option in allowed if option == value), None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _norm(value: str) -> str:
|
|
72
|
+
return value.strip(WHITE_SPACE).translate(_ASCII_LOWER)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _choice(value: str, allowed: tuple[_T, ...], ctx: str) -> _T:
|
|
76
|
+
picked = _pick(_norm(value), allowed)
|
|
77
|
+
if picked is None:
|
|
78
|
+
raise FloeConfigError(
|
|
79
|
+
f"unsupported value at {ctx}: {value} (allowed: {', '.join(allowed)})"
|
|
80
|
+
)
|
|
81
|
+
return picked
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _each(items: list[object], prefix: str, parse: Callable[[object], _R]) -> list[_R]:
|
|
85
|
+
out: list[_R] = []
|
|
86
|
+
for index, item in enumerate(items):
|
|
87
|
+
try:
|
|
88
|
+
out.append(parse(item))
|
|
89
|
+
except FloeError as e:
|
|
90
|
+
raise FloeConfigError(f"{prefix}[{index}]: {e}") from e
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _metadata(value: object) -> EntityMetadata:
|
|
95
|
+
h = yaml_hash(value, "entity.metadata")
|
|
96
|
+
return EntityMetadata(
|
|
97
|
+
data_product=opt_string(h, "data_product", "entity.metadata"),
|
|
98
|
+
domain=opt_string(h, "domain", "entity.metadata"),
|
|
99
|
+
owner=opt_string(h, "owner", "entity.metadata"),
|
|
100
|
+
description=opt_string(h, "description", "entity.metadata"),
|
|
101
|
+
tags=opt_vec_string(h, "tags", "entity.metadata"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _state(value: object) -> EntityStateConfig:
|
|
106
|
+
h = yaml_hash(value, "entity.state")
|
|
107
|
+
validate_known_keys(h, "entity.state", ("path",))
|
|
108
|
+
return EntityStateConfig(path=opt_string(h, "path", "entity.state"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _policy(value: object) -> PolicyConfig:
|
|
112
|
+
h = yaml_hash(value, "policy")
|
|
113
|
+
validate_known_keys(h, "policy", ("severity",))
|
|
114
|
+
severity = get_string(h, "severity", "policy")
|
|
115
|
+
picked = _pick(severity, get_args(PolicySeverity))
|
|
116
|
+
if picked is None:
|
|
117
|
+
raise FloeConfigError(
|
|
118
|
+
f"policy.severity={severity} is unsupported (allowed: warn, reject, abort)"
|
|
119
|
+
)
|
|
120
|
+
return PolicyConfig(severity=picked)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _normalize_columns(value: object) -> NormalizeColumnsConfig:
|
|
124
|
+
ctx = "schema.normalize_columns"
|
|
125
|
+
h = yaml_hash(value, ctx)
|
|
126
|
+
validate_known_keys(h, ctx, ("enabled", "strategy"))
|
|
127
|
+
return NormalizeColumnsConfig(
|
|
128
|
+
enabled=opt_bool(h, "enabled", ctx), strategy=opt_string(h, "strategy", ctx)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _mismatch(value: object) -> SchemaMismatchConfig:
|
|
133
|
+
h = yaml_hash(value, "schema.mismatch")
|
|
134
|
+
validate_known_keys(h, "schema.mismatch", ("missing_columns", "extra_columns"))
|
|
135
|
+
return SchemaMismatchConfig(
|
|
136
|
+
missing_columns=opt_string(h, "missing_columns", "schema.mismatch"),
|
|
137
|
+
extra_columns=opt_string(h, "extra_columns", "schema.mismatch"),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _schema_evolution(value: object) -> SchemaEvolutionConfig:
|
|
142
|
+
ctx = "schema.schema_evolution"
|
|
143
|
+
h = yaml_hash(value, ctx)
|
|
144
|
+
validate_known_keys(h, ctx, ("mode", "on_incompatible"))
|
|
145
|
+
mode = opt_string(h, "mode", ctx)
|
|
146
|
+
mode_value: SchemaEvolutionMode = (
|
|
147
|
+
"strict"
|
|
148
|
+
if mode is None
|
|
149
|
+
else _choice(mode, get_args(SchemaEvolutionMode), f"{ctx}.mode")
|
|
150
|
+
)
|
|
151
|
+
action = opt_string(h, "on_incompatible", ctx)
|
|
152
|
+
return SchemaEvolutionConfig(
|
|
153
|
+
mode=mode_value,
|
|
154
|
+
on_incompatible="fail"
|
|
155
|
+
if action is None
|
|
156
|
+
else _choice(
|
|
157
|
+
action,
|
|
158
|
+
get_args(SchemaEvolutionIncompatibleAction),
|
|
159
|
+
f"{ctx}.on_incompatible",
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _column(value: object) -> ColumnConfig:
|
|
165
|
+
ctx = "schema.columns"
|
|
166
|
+
h = yaml_hash(value, ctx)
|
|
167
|
+
validate_known_keys(
|
|
168
|
+
h, ctx, ("name", "source", "type", "nullable", "unique", "width", "trim")
|
|
169
|
+
)
|
|
170
|
+
return ColumnConfig(
|
|
171
|
+
name=get_string(h, "name", ctx),
|
|
172
|
+
source=opt_string(h, "source", ctx),
|
|
173
|
+
column_type=get_string(h, "type", ctx),
|
|
174
|
+
nullable=opt_bool(h, "nullable", ctx),
|
|
175
|
+
unique=opt_bool(h, "unique", ctx),
|
|
176
|
+
width=opt_u64(h, "width", ctx),
|
|
177
|
+
trim=opt_bool(h, "trim", ctx),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _schema(value: object) -> SchemaConfig:
|
|
182
|
+
h = yaml_hash(value, "schema")
|
|
183
|
+
validate_known_keys(
|
|
184
|
+
h,
|
|
185
|
+
"schema",
|
|
186
|
+
(
|
|
187
|
+
"normalize_columns",
|
|
188
|
+
"mismatch",
|
|
189
|
+
"schema_evolution",
|
|
190
|
+
"primary_key",
|
|
191
|
+
"unique_keys",
|
|
192
|
+
"columns",
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
normalize = (
|
|
196
|
+
_normalize_columns(h["normalize_columns"]) if "normalize_columns" in h else None
|
|
197
|
+
)
|
|
198
|
+
mismatch = _mismatch(h["mismatch"]) if "mismatch" in h else None
|
|
199
|
+
evolution = (
|
|
200
|
+
_schema_evolution(h["schema_evolution"]) if "schema_evolution" in h else None
|
|
201
|
+
)
|
|
202
|
+
primary_key = opt_vec_string(h, "primary_key", "schema")
|
|
203
|
+
unique_keys = opt_vec_vec_string(h, "unique_keys", "schema")
|
|
204
|
+
columns = _each(get_array(h, "columns", "schema"), "schema.columns", _column)
|
|
205
|
+
return SchemaConfig(
|
|
206
|
+
normalize_columns=normalize,
|
|
207
|
+
mismatch=mismatch,
|
|
208
|
+
schema_evolution=evolution,
|
|
209
|
+
primary_key=primary_key,
|
|
210
|
+
unique_keys=unique_keys,
|
|
211
|
+
columns=columns,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _pii_column(value: object) -> PiiColumn:
|
|
216
|
+
ctx = "pii.columns"
|
|
217
|
+
h = yaml_hash(value, ctx)
|
|
218
|
+
validate_known_keys(
|
|
219
|
+
h, ctx, ("name", "strategy", "mask_pattern", "redact_value", "key")
|
|
220
|
+
)
|
|
221
|
+
name = get_string(h, "name", ctx)
|
|
222
|
+
key = _norm(get_string(h, "strategy", ctx))
|
|
223
|
+
strategy = _pick(key, get_args(Strategy))
|
|
224
|
+
if strategy is None:
|
|
225
|
+
raise FloeConfigError(
|
|
226
|
+
f"pii.columns[name={name}].strategy={key} is unsupported "
|
|
227
|
+
"(allowed: hash, drop, nullify, redact, mask)"
|
|
228
|
+
)
|
|
229
|
+
return PiiColumn(
|
|
230
|
+
name=name,
|
|
231
|
+
strategy=strategy,
|
|
232
|
+
mask_pattern=opt_string(h, "mask_pattern", ctx),
|
|
233
|
+
redact_value=opt_string(h, "redact_value", ctx),
|
|
234
|
+
key=opt_string(h, "key", ctx),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _pii(value: object) -> PiiConfig:
|
|
239
|
+
h = yaml_hash(value, "pii")
|
|
240
|
+
validate_known_keys(h, "pii", ("columns",))
|
|
241
|
+
return PiiConfig(
|
|
242
|
+
columns=_each(get_array(h, "columns", "pii"), "pii.columns", _pii_column)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _source_options(value: object, defaults: SourceOptions) -> SourceOptions:
|
|
247
|
+
ctx = "source.options"
|
|
248
|
+
h = yaml_hash(value, ctx)
|
|
249
|
+
validate_known_keys(h, ctx, tuple(SourceOptions.model_fields))
|
|
250
|
+
readers: dict[str, Callable[[dict[object, object], str, str], object]] = {
|
|
251
|
+
**dict.fromkeys(("header", "recursive"), opt_bool),
|
|
252
|
+
**dict.fromkeys(("header_row", "data_row"), opt_u64),
|
|
253
|
+
"null_values": opt_vec_string,
|
|
254
|
+
}
|
|
255
|
+
read = {
|
|
256
|
+
k: readers.get(k, opt_string)(h, k, ctx) for k in SourceOptions.model_fields
|
|
257
|
+
}
|
|
258
|
+
# a null or absent key keeps the format default
|
|
259
|
+
return defaults.model_copy(update={k: v for k, v in read.items() if v is not None})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _storage(h: dict[object, object], ctx: str) -> str | None:
|
|
263
|
+
storage = opt_string(h, "storage", ctx)
|
|
264
|
+
filesystem = opt_string(h, "filesystem", ctx)
|
|
265
|
+
if storage is not None and filesystem is not None:
|
|
266
|
+
raise FloeConfigError(
|
|
267
|
+
f"{ctx}.storage and {ctx}.filesystem are mutually exclusive"
|
|
268
|
+
)
|
|
269
|
+
return _or(storage, filesystem)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _partitioning(value: object) -> SourcePartitioning:
|
|
273
|
+
ctx = "source.partitioning"
|
|
274
|
+
h = yaml_hash(value, ctx)
|
|
275
|
+
validate_known_keys(h, ctx, tuple(SourcePartitioning.model_fields))
|
|
276
|
+
return SourcePartitioning(
|
|
277
|
+
pattern=get_string(h, "pattern", ctx),
|
|
278
|
+
date_column=get_string(h, "date_column", ctx),
|
|
279
|
+
batch_column=opt_string(h, "batch_column", ctx),
|
|
280
|
+
lookback_days=_or(opt_u32(h, "lookback_days", ctx), 1),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _source(value: object, ref: str | None, resource: str | None) -> SourceConfig:
|
|
285
|
+
h = yaml_hash(value, "source")
|
|
286
|
+
validate_known_keys(
|
|
287
|
+
h,
|
|
288
|
+
"source",
|
|
289
|
+
(
|
|
290
|
+
"format",
|
|
291
|
+
"path",
|
|
292
|
+
"storage",
|
|
293
|
+
"filesystem",
|
|
294
|
+
"options",
|
|
295
|
+
"cast_mode",
|
|
296
|
+
"partitioning", # D28
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
fmt = get_string(h, "format", "source")
|
|
300
|
+
defaults = SourceOptions(separator="\t") if fmt == "tsv" else SourceOptions()
|
|
301
|
+
options = _source_options(h["options"], defaults) if "options" in h else defaults
|
|
302
|
+
return SourceConfig(
|
|
303
|
+
format=fmt,
|
|
304
|
+
storage=_storage(h, "source"),
|
|
305
|
+
path=get_string(h, "path", "source"),
|
|
306
|
+
options=options,
|
|
307
|
+
cast_mode=_or(opt_string(h, "cast_mode", "source"), "strict"),
|
|
308
|
+
source_ref=ref,
|
|
309
|
+
resource=resource,
|
|
310
|
+
partitioning=_partitioning(h["partitioning"]) if "partitioning" in h else None,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _sink_options(value: object, ctx: str) -> SinkOptions:
|
|
315
|
+
h = yaml_hash(value, ctx)
|
|
316
|
+
validate_known_keys(h, ctx, tuple(SinkOptions.model_fields))
|
|
317
|
+
return SinkOptions(
|
|
318
|
+
compression=opt_string(h, "compression", ctx),
|
|
319
|
+
row_group_size=opt_u64(h, "row_group_size", ctx),
|
|
320
|
+
max_size_per_file=opt_u64(h, "max_size_per_file", ctx),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _strings_only(
|
|
325
|
+
model: type[_M], parent: dict[object, object], key: str, ctx: str
|
|
326
|
+
) -> _M | None:
|
|
327
|
+
if key not in parent:
|
|
328
|
+
return None
|
|
329
|
+
ctx = f"{ctx}.{key}"
|
|
330
|
+
h = yaml_hash(parent[key], ctx)
|
|
331
|
+
validate_known_keys(h, ctx, tuple(model.model_fields))
|
|
332
|
+
return model(**{key: opt_string(h, key, ctx) for key in model.model_fields})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _merge(value: object, ctx: str) -> MergeOptions:
|
|
336
|
+
h = yaml_hash(value, ctx)
|
|
337
|
+
validate_known_keys(h, ctx, tuple(MergeOptions.model_fields))
|
|
338
|
+
return MergeOptions(
|
|
339
|
+
ignore_columns=opt_vec_string(h, "ignore_columns", ctx),
|
|
340
|
+
compare_columns=opt_vec_string(h, "compare_columns", ctx),
|
|
341
|
+
scd2=_strings_only(MergeScd2Options, h, "scd2", ctx),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _partition_spec(
|
|
346
|
+
parent: dict[object, object], ctx: str
|
|
347
|
+
) -> list[IcebergPartitionField] | None:
|
|
348
|
+
if "partition_spec" not in parent:
|
|
349
|
+
return None
|
|
350
|
+
ctx = f"{ctx}.partition_spec"
|
|
351
|
+
out: list[IcebergPartitionField] = []
|
|
352
|
+
for index, item in enumerate(yaml_array(parent["partition_spec"], ctx)):
|
|
353
|
+
item_ctx = f"{ctx}[{index}]"
|
|
354
|
+
h = yaml_hash(item, item_ctx)
|
|
355
|
+
validate_known_keys(h, item_ctx, ("column", "transform", "num_buckets"))
|
|
356
|
+
out.append(
|
|
357
|
+
IcebergPartitionField(
|
|
358
|
+
column=get_string(h, "column", item_ctx),
|
|
359
|
+
transform=_or(opt_string(h, "transform", item_ctx), "identity"),
|
|
360
|
+
num_buckets=opt_u32(h, "num_buckets", item_ctx),
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _sink_target(
|
|
367
|
+
value: object, ctx: str, allow_options: bool, write_mode: WriteMode
|
|
368
|
+
) -> SinkTarget:
|
|
369
|
+
h = yaml_hash(value, ctx)
|
|
370
|
+
allowed = ("format", "path", "storage", "filesystem")
|
|
371
|
+
if allow_options:
|
|
372
|
+
allowed += ("options", "merge", "iceberg", "delta", "duckdb")
|
|
373
|
+
allowed += ("partition_by", "partition_spec")
|
|
374
|
+
validate_known_keys(h, ctx, allowed)
|
|
375
|
+
storage = _storage(h, ctx)
|
|
376
|
+
options = _sink_options(h["options"], f"{ctx}.options") if "options" in h else None
|
|
377
|
+
merge = _merge(h["merge"], f"{ctx}.merge") if "merge" in h else None
|
|
378
|
+
partition_by = opt_vec_string(h, "partition_by", ctx)
|
|
379
|
+
iceberg = _strings_only(IcebergSinkOptions, h, "iceberg", ctx)
|
|
380
|
+
fmt = get_string(h, "format", ctx)
|
|
381
|
+
if "duckdb" in h and fmt != "duckdb":
|
|
382
|
+
raise FloeConfigError(
|
|
383
|
+
f'{ctx}.duckdb is only valid when format is "duckdb" (found format={rust_debug(fmt)})'
|
|
384
|
+
)
|
|
385
|
+
spec = _partition_spec(h, ctx)
|
|
386
|
+
return SinkTarget(
|
|
387
|
+
format=fmt,
|
|
388
|
+
# a MotherDuck sink has no path; D1 rejects duckdb in validation (#633)
|
|
389
|
+
path=opt_string(h, "path", ctx) or ""
|
|
390
|
+
if fmt == "duckdb"
|
|
391
|
+
else get_string(h, "path", ctx),
|
|
392
|
+
storage=storage,
|
|
393
|
+
options=options,
|
|
394
|
+
merge=merge,
|
|
395
|
+
iceberg=iceberg,
|
|
396
|
+
delta=h.get("delta"), # D1: dropped, left raw for validation (#633)
|
|
397
|
+
duckdb=h.get("duckdb"),
|
|
398
|
+
partition_by=partition_by,
|
|
399
|
+
partition_spec=spec,
|
|
400
|
+
write_mode=write_mode,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _archive(value: object) -> ArchiveTarget:
|
|
405
|
+
h = yaml_hash(value, "sink.archive")
|
|
406
|
+
validate_known_keys(h, "sink.archive", ("path", "storage"))
|
|
407
|
+
return ArchiveTarget(
|
|
408
|
+
path=_or(opt_string(h, "path", "sink.archive"), "archive"),
|
|
409
|
+
storage=opt_string(h, "storage", "sink.archive"),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _sink(value: object) -> SinkConfig:
|
|
414
|
+
h = yaml_hash(value, "sink")
|
|
415
|
+
validate_known_keys(h, "sink", ("write_mode", "accepted", "rejected", "archive"))
|
|
416
|
+
mode = _or(opt_string(h, "write_mode", "sink"), "overwrite")
|
|
417
|
+
write_mode = _choice(mode, get_args(WriteMode), "sink.write_mode")
|
|
418
|
+
rejected = (
|
|
419
|
+
_sink_target(h["rejected"], "sink.rejected", False, write_mode)
|
|
420
|
+
if "rejected" in h
|
|
421
|
+
else None
|
|
422
|
+
)
|
|
423
|
+
archive = _archive(h["archive"]) if "archive" in h else None
|
|
424
|
+
accepted = _sink_target(
|
|
425
|
+
get_value(h, "accepted", "sink"), "sink.accepted", True, write_mode
|
|
426
|
+
)
|
|
427
|
+
return SinkConfig(
|
|
428
|
+
write_mode=write_mode, accepted=accepted, rejected=rejected, archive=archive
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def parse_entity(
|
|
433
|
+
value: object, source_ref: str | None = None, resource: str | None = None
|
|
434
|
+
) -> EntityConfig:
|
|
435
|
+
h = yaml_hash(value, "entity")
|
|
436
|
+
validate_known_keys(
|
|
437
|
+
h,
|
|
438
|
+
"entity",
|
|
439
|
+
(
|
|
440
|
+
"name",
|
|
441
|
+
"metadata",
|
|
442
|
+
"domain",
|
|
443
|
+
"incremental_mode",
|
|
444
|
+
"state",
|
|
445
|
+
"source",
|
|
446
|
+
"sink",
|
|
447
|
+
"policy",
|
|
448
|
+
"schema",
|
|
449
|
+
"pii",
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
name = get_string(h, "name", "entity")
|
|
453
|
+
metadata = _metadata(h["metadata"]) if "metadata" in h else None
|
|
454
|
+
domain = opt_string(h, "domain", "entity")
|
|
455
|
+
mode = opt_string(h, "incremental_mode", "entity")
|
|
456
|
+
incremental_mode: IncrementalMode = (
|
|
457
|
+
"none"
|
|
458
|
+
if mode is None
|
|
459
|
+
else _choice(mode, get_args(IncrementalMode), "entity.incremental_mode")
|
|
460
|
+
)
|
|
461
|
+
state = _state(h["state"]) if "state" in h else None
|
|
462
|
+
source = _source(get_value(h, "source", "entity"), source_ref, resource)
|
|
463
|
+
sink = _sink(get_value(h, "sink", "entity"))
|
|
464
|
+
policy = _policy(get_value(h, "policy", "entity"))
|
|
465
|
+
schema = _schema(get_value(h, "schema", "entity"))
|
|
466
|
+
pii = _pii(h["pii"]) if "pii" in h else None
|
|
467
|
+
return EntityConfig(
|
|
468
|
+
name=name,
|
|
469
|
+
id=name,
|
|
470
|
+
metadata=metadata,
|
|
471
|
+
domain=domain,
|
|
472
|
+
incremental_mode=incremental_mode,
|
|
473
|
+
state=state,
|
|
474
|
+
source=source,
|
|
475
|
+
sink=sink,
|
|
476
|
+
policy=policy,
|
|
477
|
+
schema=schema,
|
|
478
|
+
pii=pii,
|
|
479
|
+
)
|
floe/config/include.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Include assembly, ported from `config/parse.rs` (`assemble_includes`)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from floe.config.project import V0_3, Hash, IncludeOrigins, at_file, config_version
|
|
8
|
+
from floe.config.vars import resolve_local_path
|
|
9
|
+
from floe.config.yaml import (
|
|
10
|
+
get_string,
|
|
11
|
+
get_value,
|
|
12
|
+
load_single_doc,
|
|
13
|
+
opt_string,
|
|
14
|
+
rust_debug,
|
|
15
|
+
validate_known_keys,
|
|
16
|
+
yaml_array,
|
|
17
|
+
yaml_hash,
|
|
18
|
+
yaml_string,
|
|
19
|
+
)
|
|
20
|
+
from floe.errors import FloeConfigError
|
|
21
|
+
from floe.glob import Pattern, glob
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def glob_base(directory: Path) -> str:
|
|
25
|
+
"""Rust `glob_base`: escaping would turn a Windows verbatim prefix's `?` into `[?]`."""
|
|
26
|
+
text = str(directory)
|
|
27
|
+
if text.startswith("\\\\?\\UNC\\"):
|
|
28
|
+
text = "\\\\" + text[8:]
|
|
29
|
+
else:
|
|
30
|
+
text = text.removeprefix("\\\\?\\")
|
|
31
|
+
return Pattern.escape(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assemble_includes(
|
|
35
|
+
doc: object, config_dir: Path, config_files: list[Path]
|
|
36
|
+
) -> IncludeOrigins:
|
|
37
|
+
"""Splice included sources, domains and entities into `doc` so parsing sees one document."""
|
|
38
|
+
if not isinstance(doc, dict) or "include" not in doc:
|
|
39
|
+
return IncludeOrigins()
|
|
40
|
+
root = cast(Hash, doc)
|
|
41
|
+
include = root.pop("include")
|
|
42
|
+
version = root.get("version")
|
|
43
|
+
try:
|
|
44
|
+
supported = isinstance(version, str) and config_version(version) >= V0_3
|
|
45
|
+
except FloeConfigError:
|
|
46
|
+
supported = False
|
|
47
|
+
if not supported:
|
|
48
|
+
raise FloeConfigError('root.include requires root.version >= "0.3"')
|
|
49
|
+
include = yaml_hash(include, "include")
|
|
50
|
+
validate_known_keys(include, "include", ("domains", "sources"))
|
|
51
|
+
sources_path = None
|
|
52
|
+
if "sources" in include:
|
|
53
|
+
if "sources" in root:
|
|
54
|
+
raise FloeConfigError(
|
|
55
|
+
"root.sources and include.sources are mutually exclusive"
|
|
56
|
+
)
|
|
57
|
+
sources_path = resolve_local_path(
|
|
58
|
+
config_dir, yaml_string(include["sources"], "include.sources")
|
|
59
|
+
)
|
|
60
|
+
with at_file(sources_path):
|
|
61
|
+
sources_root = yaml_hash(load_single_doc(sources_path), "sources")
|
|
62
|
+
validate_known_keys(sources_root, "root", ("sources",))
|
|
63
|
+
root["sources"] = get_value(sources_root, "sources", "root")
|
|
64
|
+
config_files.append(sources_path)
|
|
65
|
+
if "domains" in include:
|
|
66
|
+
patterns = yaml_array(include["domains"], "include.domains")
|
|
67
|
+
elif sources_path is not None:
|
|
68
|
+
patterns = []
|
|
69
|
+
else:
|
|
70
|
+
raise FloeConfigError("missing required field include.domains")
|
|
71
|
+
domains: list[tuple[object, Path]] = []
|
|
72
|
+
entities: list[tuple[object, Path]] = []
|
|
73
|
+
for raw in patterns:
|
|
74
|
+
pattern = yaml_string(raw, "include.domains")
|
|
75
|
+
matches = glob(os.path.join(glob_base(config_dir), pattern))
|
|
76
|
+
if not matches:
|
|
77
|
+
raise FloeConfigError(
|
|
78
|
+
f"include.domains pattern {rust_debug(pattern)} matched no file "
|
|
79
|
+
"(includes resolve against local files only; a remote project cannot include)"
|
|
80
|
+
)
|
|
81
|
+
for match in matches:
|
|
82
|
+
domain_file = Path(match)
|
|
83
|
+
with at_file(domain_file):
|
|
84
|
+
domain = load_single_doc(domain_file)
|
|
85
|
+
name = get_string(yaml_hash(domain, "domains"), "name", "domains")
|
|
86
|
+
config_files.append(domain_file)
|
|
87
|
+
domains.append((domain, domain_file))
|
|
88
|
+
# D4: an entity's name comes from its YAML, never from its file name, so `con.yml` loads like any other
|
|
89
|
+
siblings = [
|
|
90
|
+
p
|
|
91
|
+
for p in domain_file.parent.iterdir()
|
|
92
|
+
if p != domain_file and p.suffix == ".yml"
|
|
93
|
+
]
|
|
94
|
+
for entity_file in sorted(siblings):
|
|
95
|
+
with at_file(entity_file):
|
|
96
|
+
loaded = load_single_doc(entity_file)
|
|
97
|
+
if not isinstance(loaded, dict):
|
|
98
|
+
raise FloeConfigError("expected map at entity")
|
|
99
|
+
entity = cast(Hash, loaded)
|
|
100
|
+
declared = opt_string(entity, "domain", "entity")
|
|
101
|
+
if declared is None:
|
|
102
|
+
entity.pop("domain", None) # a re-inserted key moves to the back
|
|
103
|
+
entity["domain"] = name
|
|
104
|
+
elif declared != name:
|
|
105
|
+
raise FloeConfigError(
|
|
106
|
+
f"{entity_file} has domain {rust_debug(declared)} but its _domain.yml names {rust_debug(name)}"
|
|
107
|
+
)
|
|
108
|
+
config_files.append(entity_file)
|
|
109
|
+
entities.append((entity, entity_file))
|
|
110
|
+
origins = IncludeOrigins(root=config_files[0], sources=sources_path)
|
|
111
|
+
for key, items, item_origins in (
|
|
112
|
+
("domains", domains, origins.domains),
|
|
113
|
+
("entities", entities, origins.entities),
|
|
114
|
+
):
|
|
115
|
+
array = root.setdefault(key, [])
|
|
116
|
+
if not isinstance(array, list):
|
|
117
|
+
raise FloeConfigError(f"expected array at {key}")
|
|
118
|
+
item_origins += [None] * len(cast("list[object]", array))
|
|
119
|
+
for item, file in items:
|
|
120
|
+
cast("list[object]", array).append(item)
|
|
121
|
+
item_origins.append(file)
|
|
122
|
+
return origins
|