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/project.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Project root, domains and named sources, ported from `config/parse.rs` (`parse_root`, `parse_domains`, `parse_sources`)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, SecretStr
|
|
11
|
+
|
|
12
|
+
from floe.config.entity import parse_entity
|
|
13
|
+
from floe.config.storage import (
|
|
14
|
+
CatalogsConfig,
|
|
15
|
+
StoragesConfig,
|
|
16
|
+
parse_catalogs,
|
|
17
|
+
parse_storages,
|
|
18
|
+
)
|
|
19
|
+
from floe.config.types import EntityConfig
|
|
20
|
+
from floe.config.vars import string_map
|
|
21
|
+
from floe.config.yaml import (
|
|
22
|
+
get_array,
|
|
23
|
+
get_string,
|
|
24
|
+
merge_yaml,
|
|
25
|
+
opt_string,
|
|
26
|
+
opt_u32,
|
|
27
|
+
opt_u64,
|
|
28
|
+
opt_vec_string,
|
|
29
|
+
validate_known_keys,
|
|
30
|
+
yaml_array,
|
|
31
|
+
yaml_hash,
|
|
32
|
+
yaml_string,
|
|
33
|
+
)
|
|
34
|
+
from floe.errors import FloeConfigError, FloeError, FloeIoError
|
|
35
|
+
|
|
36
|
+
Hash = dict[object, object]
|
|
37
|
+
V0_3 = (0, 3)
|
|
38
|
+
ROOT_KEYS = (
|
|
39
|
+
*("version", "metadata", "storages", "filesystems", "catalogs", "env"),
|
|
40
|
+
*("domains", "sources", "report", "lineage", "entities"),
|
|
41
|
+
)
|
|
42
|
+
SOURCE_KEYS = (
|
|
43
|
+
*("format", "path", "storage", "filesystem", "options", "cast_mode"),
|
|
44
|
+
"partitioning", # D28
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProjectMetadata(BaseModel):
|
|
49
|
+
project: str | None
|
|
50
|
+
description: str | None
|
|
51
|
+
owner: str | None
|
|
52
|
+
tags: list[str] | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EnvConfig(BaseModel):
|
|
56
|
+
file: str | None
|
|
57
|
+
vars: dict[str, str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ReportConfig(BaseModel):
|
|
61
|
+
path: str
|
|
62
|
+
formatter: str | None
|
|
63
|
+
storage: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LineageConfig(BaseModel):
|
|
67
|
+
url: str
|
|
68
|
+
endpoint: str | None
|
|
69
|
+
api_key: SecretStr | None
|
|
70
|
+
timeout_secs: int | None
|
|
71
|
+
namespace: str
|
|
72
|
+
dataset_namespace: str | None
|
|
73
|
+
producer: str | None
|
|
74
|
+
max_failures: int | None
|
|
75
|
+
job_name: str | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DomainConfig(BaseModel):
|
|
79
|
+
name: str
|
|
80
|
+
incoming_dir: str | None
|
|
81
|
+
defaults: Hash | None # raw; `validate_domains` rejects the keys D7 forbids
|
|
82
|
+
resolved_incoming_dir: str | None = None # set by the template pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RootConfig(BaseModel):
|
|
86
|
+
version: str
|
|
87
|
+
metadata: ProjectMetadata | None
|
|
88
|
+
storages: StoragesConfig | None
|
|
89
|
+
catalogs: CatalogsConfig | None
|
|
90
|
+
env: EnvConfig | None
|
|
91
|
+
domains: list[DomainConfig]
|
|
92
|
+
report: ReportConfig
|
|
93
|
+
lineage: LineageConfig | None
|
|
94
|
+
entities: list[EntityConfig]
|
|
95
|
+
config_files: list[Path]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class IncludeOrigins:
|
|
100
|
+
"""Originating file of each `domains` and `entities` item by index; `None` for inline items."""
|
|
101
|
+
|
|
102
|
+
root: Path | None = None
|
|
103
|
+
sources: Path | None = None
|
|
104
|
+
domains: list[Path | None] = field(default_factory=list[Path | None])
|
|
105
|
+
entities: list[Path | None] = field(default_factory=list[Path | None])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def at_file(path: Path | None) -> Generator[None]:
|
|
110
|
+
"""`with_source_path` / `with_origin`: name the file an error came from."""
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
except FloeError as e:
|
|
114
|
+
if path is None:
|
|
115
|
+
raise
|
|
116
|
+
raise (FloeIoError if isinstance(e, FloeIoError) else FloeConfigError)(
|
|
117
|
+
f"{path}: {e}"
|
|
118
|
+
) from e
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_VERSION = re.compile(r"\+?0*([0-9]{1,20})\.\+?0*([0-9]{1,20})")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def config_version(raw: str) -> tuple[int, int]:
|
|
125
|
+
match = _VERSION.fullmatch(raw)
|
|
126
|
+
version = (int(match[1]), int(match[2])) if match else (2**64, 0)
|
|
127
|
+
if max(version) >= 2**64:
|
|
128
|
+
raise FloeConfigError(
|
|
129
|
+
f'root.version={raw} is invalid; expected numeric major.minor format like "0.1"'
|
|
130
|
+
)
|
|
131
|
+
return version
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def supports_v0_3(version: object) -> bool:
|
|
135
|
+
try:
|
|
136
|
+
return isinstance(version, str) and config_version(version) >= V0_3
|
|
137
|
+
except FloeConfigError:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _field(value: object, key: str) -> object:
|
|
142
|
+
return cast(Hash, value).get(key) if isinstance(value, dict) else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hint(entity: object, key: str) -> str | None:
|
|
146
|
+
value = _field(entity, key)
|
|
147
|
+
return value if isinstance(value, str) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_domain(item: object, version: str) -> DomainConfig:
|
|
151
|
+
hash = yaml_hash(item, "domains")
|
|
152
|
+
validate_known_keys(hash, "domains", ("name", "incoming_dir", "defaults"))
|
|
153
|
+
name = get_string(hash, "name", "domains")
|
|
154
|
+
defaults = None
|
|
155
|
+
if "defaults" in hash:
|
|
156
|
+
if config_version(version) < V0_3:
|
|
157
|
+
raise FloeConfigError('domains.defaults requires root.version >= "0.3"')
|
|
158
|
+
defaults = yaml_hash(hash["defaults"], "domains.defaults")
|
|
159
|
+
incoming_dir = opt_string(hash, "incoming_dir", "domains")
|
|
160
|
+
if incoming_dir is None and config_version(version) < V0_3:
|
|
161
|
+
raise FloeConfigError("missing required field domains.incoming_dir")
|
|
162
|
+
return DomainConfig(name=name, incoming_dir=incoming_dir, defaults=defaults)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_sources(value: object, version: str, origin: Path | None) -> dict[str, Hash]:
|
|
166
|
+
with at_file(origin):
|
|
167
|
+
return _parse_sources(value, version)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_sources(value: object, version: str) -> dict[str, Hash]:
|
|
171
|
+
if config_version(version) < V0_3:
|
|
172
|
+
raise FloeConfigError('root.sources requires root.version >= "0.3"')
|
|
173
|
+
sources: dict[str, Hash] = {}
|
|
174
|
+
for item in yaml_array(value, "sources"):
|
|
175
|
+
hash = dict(yaml_hash(item, "sources"))
|
|
176
|
+
validate_known_keys(hash, "sources", ("name", *SOURCE_KEYS))
|
|
177
|
+
name = get_string(hash, "name", "sources")
|
|
178
|
+
del hash["name"]
|
|
179
|
+
if name in sources:
|
|
180
|
+
raise FloeConfigError(f"duplicate source name {name}")
|
|
181
|
+
sources[name] = hash
|
|
182
|
+
return sources
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_source_ref(
|
|
186
|
+
entity: object, sources: dict[str, Hash]
|
|
187
|
+
) -> tuple[Hash, str, str] | None:
|
|
188
|
+
source = _field(entity, "source")
|
|
189
|
+
if not isinstance(source, dict) or "ref" not in source:
|
|
190
|
+
return None
|
|
191
|
+
source = cast(Hash, source)
|
|
192
|
+
overlay = {k: v for k, v in source.items() if k != "ref"}
|
|
193
|
+
name = yaml_string(source["ref"], "source.ref")
|
|
194
|
+
if name not in sources:
|
|
195
|
+
available = ", ".join(sources)
|
|
196
|
+
raise FloeConfigError(
|
|
197
|
+
f"source.ref={name} is not a declared source (available: {available})"
|
|
198
|
+
)
|
|
199
|
+
if "resource" in overlay:
|
|
200
|
+
resource = yaml_string(overlay.pop("resource"), "source.resource")
|
|
201
|
+
else:
|
|
202
|
+
resource = _hint(entity, "name") or ""
|
|
203
|
+
resolved = dict(cast(Hash, entity))
|
|
204
|
+
resolved.pop("source") # a re-inserted key moves to the back, as in hashlink
|
|
205
|
+
resolved["source"] = merge_yaml(sources[name], overlay)
|
|
206
|
+
return resolved, name, resource
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _merge_entity(
|
|
210
|
+
entity: object, sources: dict[str, Hash], defaults: dict[str, Hash]
|
|
211
|
+
) -> tuple[object, str | None, str | None]:
|
|
212
|
+
ref = resource = None
|
|
213
|
+
if resolved := resolve_source_ref(entity, sources):
|
|
214
|
+
entity, ref, resource = resolved
|
|
215
|
+
domain = _hint(entity, "domain")
|
|
216
|
+
merged = merge_yaml(defaults[domain], entity) if domain in defaults else entity
|
|
217
|
+
source = _field(merged, "source") if resource is not None else None
|
|
218
|
+
if isinstance(source, dict):
|
|
219
|
+
source = cast(Hash, source)
|
|
220
|
+
if isinstance(source.get("path"), str):
|
|
221
|
+
# `{{resource}}` is entity-specific; the template pass leaves it alone
|
|
222
|
+
source["path"] = re.sub(
|
|
223
|
+
r"\{\{(.*?)\}\}",
|
|
224
|
+
lambda m: str(resource) if m[1].strip() == "resource" else m[0],
|
|
225
|
+
cast(str, source["path"]),
|
|
226
|
+
flags=re.DOTALL,
|
|
227
|
+
)
|
|
228
|
+
return merged, ref, resource
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_entities(
|
|
232
|
+
entities: list[object],
|
|
233
|
+
origins: IncludeOrigins,
|
|
234
|
+
domains: list[DomainConfig],
|
|
235
|
+
defaults: dict[str, Hash],
|
|
236
|
+
sources: dict[str, Hash],
|
|
237
|
+
qualified: bool,
|
|
238
|
+
) -> list[EntityConfig]:
|
|
239
|
+
result: list[EntityConfig] = []
|
|
240
|
+
for index, entity in enumerate(entities):
|
|
241
|
+
try:
|
|
242
|
+
parsed = parse_entity(*_merge_entity(entity, sources, defaults))
|
|
243
|
+
if qualified and parsed.domain is not None:
|
|
244
|
+
parsed = parsed.model_copy(
|
|
245
|
+
update={"id": f"{parsed.domain}.{parsed.name}"}
|
|
246
|
+
)
|
|
247
|
+
result.append(parsed)
|
|
248
|
+
except FloeError as error:
|
|
249
|
+
raise _entity_error(
|
|
250
|
+
index, entity, error, origins, domains, defaults, sources
|
|
251
|
+
) from error
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _entity_error(
|
|
256
|
+
index: int,
|
|
257
|
+
entity: object,
|
|
258
|
+
error: FloeError,
|
|
259
|
+
origins: IncludeOrigins,
|
|
260
|
+
domains: list[DomainConfig],
|
|
261
|
+
defaults: dict[str, Hash],
|
|
262
|
+
sources: dict[str, Hash],
|
|
263
|
+
) -> FloeConfigError:
|
|
264
|
+
"""Name the entity, its file and the files its domain defaults and source came from."""
|
|
265
|
+
name = _hint(entity, "name")
|
|
266
|
+
who = f"entities[{index}]" + ("" if name is None else f" (entity.name={name})")
|
|
267
|
+
origin = origins.entities[index] if index < len(origins.entities) else None
|
|
268
|
+
contributors: list[Path | None] = []
|
|
269
|
+
if (domain := _hint(entity, "domain")) in defaults:
|
|
270
|
+
files = [o for d, o in zip(domains, origins.domains) if d.name == domain]
|
|
271
|
+
contributors.append(files[-1] if files else None)
|
|
272
|
+
ref = _field(_field(entity, "source"), "ref")
|
|
273
|
+
if isinstance(ref, str) and ref in sources:
|
|
274
|
+
contributors.append(origins.sources)
|
|
275
|
+
prefix = str(origin) if origin else ""
|
|
276
|
+
if any(c is not None for c in contributors):
|
|
277
|
+
inherits = ", ".join(str(c or origins.root) for c in contributors)
|
|
278
|
+
prefix = (
|
|
279
|
+
f"{prefix} (inherits from {inherits})"
|
|
280
|
+
if origin
|
|
281
|
+
else f"inherits from {inherits}"
|
|
282
|
+
)
|
|
283
|
+
return FloeConfigError(f"{prefix}: {who}: {error}" if prefix else f"{who}: {error}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_report(value: object) -> ReportConfig:
|
|
287
|
+
hash = yaml_hash(value, "report")
|
|
288
|
+
validate_known_keys(hash, "report", ("path", "formatter", "storage"))
|
|
289
|
+
path = opt_string(hash, "path", "report")
|
|
290
|
+
return ReportConfig(
|
|
291
|
+
path="report" if path is None else path,
|
|
292
|
+
formatter=opt_string(hash, "formatter", "report"),
|
|
293
|
+
storage=opt_string(hash, "storage", "report"),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_lineage(value: object) -> LineageConfig:
|
|
298
|
+
hash = yaml_hash(value, "lineage")
|
|
299
|
+
ctx = "lineage"
|
|
300
|
+
validate_known_keys(
|
|
301
|
+
hash,
|
|
302
|
+
ctx,
|
|
303
|
+
(
|
|
304
|
+
*("url", "endpoint", "api_key", "timeout_secs", "namespace"),
|
|
305
|
+
*("dataset_namespace", "producer", "max_failures", "job_name"),
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
api_key = opt_string(hash, "api_key", ctx)
|
|
309
|
+
return LineageConfig(
|
|
310
|
+
url=get_string(hash, "url", ctx),
|
|
311
|
+
endpoint=opt_string(hash, "endpoint", ctx),
|
|
312
|
+
api_key=None if api_key is None else SecretStr(api_key),
|
|
313
|
+
timeout_secs=opt_u64(hash, "timeout_secs", ctx),
|
|
314
|
+
namespace=get_string(hash, "namespace", ctx),
|
|
315
|
+
dataset_namespace=opt_string(hash, "dataset_namespace", ctx),
|
|
316
|
+
producer=opt_string(hash, "producer", ctx),
|
|
317
|
+
max_failures=opt_u32(hash, "max_failures", ctx),
|
|
318
|
+
job_name=opt_string(hash, "job_name", ctx),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_root(doc: object, origins: IncludeOrigins) -> RootConfig:
|
|
323
|
+
root = yaml_hash(doc, "root")
|
|
324
|
+
validate_known_keys(root, "root", ROOT_KEYS)
|
|
325
|
+
version = get_string(root, "version", "root")
|
|
326
|
+
metadata = None
|
|
327
|
+
if "metadata" in root:
|
|
328
|
+
meta = yaml_hash(root["metadata"], "metadata")
|
|
329
|
+
metadata = ProjectMetadata(
|
|
330
|
+
project=opt_string(meta, "project", "metadata"),
|
|
331
|
+
description=opt_string(meta, "description", "metadata"),
|
|
332
|
+
owner=opt_string(meta, "owner", "metadata"),
|
|
333
|
+
tags=opt_vec_string(meta, "tags", "metadata"),
|
|
334
|
+
)
|
|
335
|
+
if "storages" in root and "filesystems" in root:
|
|
336
|
+
raise FloeConfigError(
|
|
337
|
+
"root.storages and root.filesystems are mutually exclusive"
|
|
338
|
+
)
|
|
339
|
+
key = "storages" if "storages" in root else "filesystems"
|
|
340
|
+
storages = parse_storages(root[key]) if key in root else None
|
|
341
|
+
env = None
|
|
342
|
+
if "env" in root:
|
|
343
|
+
env_hash = yaml_hash(root["env"], "env")
|
|
344
|
+
validate_known_keys(env_hash, "env", ("file", "vars"))
|
|
345
|
+
env_vars = (
|
|
346
|
+
string_map(env_hash["vars"], "env.vars") if "vars" in env_hash else {}
|
|
347
|
+
)
|
|
348
|
+
env = EnvConfig(file=opt_string(env_hash, "file", "env"), vars=env_vars)
|
|
349
|
+
catalogs = parse_catalogs(root["catalogs"]) if "catalogs" in root else None
|
|
350
|
+
items = yaml_array(root["domains"], "domains") if "domains" in root else []
|
|
351
|
+
domains: list[DomainConfig] = []
|
|
352
|
+
for index, item in enumerate(items):
|
|
353
|
+
with at_file(origins.domains[index] if index < len(origins.domains) else None):
|
|
354
|
+
domains.append(parse_domain(item, version))
|
|
355
|
+
defaults = {d.name: d.defaults for d in domains if d.defaults is not None}
|
|
356
|
+
sources = (
|
|
357
|
+
parse_sources(root["sources"], version, origins.sources)
|
|
358
|
+
if "sources" in root
|
|
359
|
+
else {}
|
|
360
|
+
)
|
|
361
|
+
report = parse_report(root.get("report", {}))
|
|
362
|
+
lineage = parse_lineage(root["lineage"]) if "lineage" in root else None
|
|
363
|
+
entities = parse_entities(
|
|
364
|
+
get_array(root, "entities", "root"),
|
|
365
|
+
origins,
|
|
366
|
+
domains,
|
|
367
|
+
defaults,
|
|
368
|
+
sources,
|
|
369
|
+
supports_v0_3(version),
|
|
370
|
+
)
|
|
371
|
+
return RootConfig(
|
|
372
|
+
version=version,
|
|
373
|
+
metadata=metadata,
|
|
374
|
+
storages=storages,
|
|
375
|
+
catalogs=catalogs,
|
|
376
|
+
env=env,
|
|
377
|
+
domains=domains,
|
|
378
|
+
report=report,
|
|
379
|
+
lineage=lineage,
|
|
380
|
+
entities=entities,
|
|
381
|
+
config_files=[],
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def validate_domains(config: RootConfig) -> None:
|
|
386
|
+
"""D7 (#584 S6, S7): Rust 0.7 accepts these at validate time; Python rejects them."""
|
|
387
|
+
qualified = config_version(config.version) >= V0_3
|
|
388
|
+
used = {e.domain for e in config.entities} # the entity check reports a used domain
|
|
389
|
+
for domain in config.domains:
|
|
390
|
+
bad = not domain.name or any(c in domain.name for c in "./\\")
|
|
391
|
+
if qualified and bad and domain.name not in used:
|
|
392
|
+
raise FloeConfigError(
|
|
393
|
+
f"domain.name={domain.name} must be non-empty without '.', '/' or '\\' "
|
|
394
|
+
'when root.version >= "0.3"'
|
|
395
|
+
)
|
|
396
|
+
defaults = domain.defaults or {}
|
|
397
|
+
schema = defaults.get("schema")
|
|
398
|
+
forbidden = ["name"] if "name" in defaults else []
|
|
399
|
+
if isinstance(schema, dict):
|
|
400
|
+
forbidden += [
|
|
401
|
+
f"schema.{k}" for k in ("columns", "primary_key") if k in schema
|
|
402
|
+
]
|
|
403
|
+
if forbidden:
|
|
404
|
+
raise FloeConfigError(f"unknown field domains.defaults.{forbidden[0]}")
|
floe/config/storage.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Storage and catalog definitions, ported from `config/parse.rs` (`parse_storages`, `parse_catalogs_with_context`)."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, SecretStr
|
|
4
|
+
|
|
5
|
+
from floe.config.yaml import (
|
|
6
|
+
get_string,
|
|
7
|
+
opt_bool,
|
|
8
|
+
opt_string,
|
|
9
|
+
validate_known_keys,
|
|
10
|
+
yaml_array,
|
|
11
|
+
yaml_hash,
|
|
12
|
+
)
|
|
13
|
+
from floe.errors import FloeConfigError
|
|
14
|
+
|
|
15
|
+
Hash = dict[object, object]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Model(BaseModel):
|
|
19
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StorageDefinition(_Model):
|
|
23
|
+
name: str
|
|
24
|
+
type: str
|
|
25
|
+
bucket: str | None
|
|
26
|
+
region: str | None
|
|
27
|
+
account: str | None
|
|
28
|
+
container: str | None
|
|
29
|
+
prefix: str | None
|
|
30
|
+
endpoint: str | None
|
|
31
|
+
path_style_access: bool | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StoragesConfig(_Model):
|
|
35
|
+
default: str | None
|
|
36
|
+
definitions: list[StorageDefinition]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CatalogDefinition(_Model):
|
|
40
|
+
"""An Iceberg REST catalog; Glue and Unity are rejected (D1)."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
uri: str
|
|
44
|
+
credential: SecretStr | None
|
|
45
|
+
warehouse: str | None
|
|
46
|
+
oauth2_server_uri: str | None
|
|
47
|
+
scope: str | None
|
|
48
|
+
warehouse_storage: str | None
|
|
49
|
+
warehouse_prefix: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CatalogsConfig(_Model):
|
|
53
|
+
default: str | None
|
|
54
|
+
definitions: list[CatalogDefinition]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Unsupported(FloeConfigError):
|
|
58
|
+
"""A D1 rejection: raised as is, without the `definitions[i]:` prefix."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _items(value: object, context: str) -> tuple[Hash, list[object]]:
|
|
62
|
+
"""The `default` + `definitions` shape shared by `storages` and `catalogs`."""
|
|
63
|
+
hash = yaml_hash(value, context)
|
|
64
|
+
validate_known_keys(hash, context, ("default", "definitions"))
|
|
65
|
+
if "definitions" not in hash:
|
|
66
|
+
raise FloeConfigError(f"missing required field {context}.definitions")
|
|
67
|
+
return hash, yaml_array(hash["definitions"], f"{context}.definitions")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_STORAGE_KEYS = (
|
|
71
|
+
"name",
|
|
72
|
+
"type",
|
|
73
|
+
"bucket",
|
|
74
|
+
"region",
|
|
75
|
+
"account",
|
|
76
|
+
"container",
|
|
77
|
+
"prefix",
|
|
78
|
+
"endpoint",
|
|
79
|
+
"path_style_access",
|
|
80
|
+
)
|
|
81
|
+
_CATALOG_KEYS = (
|
|
82
|
+
"name",
|
|
83
|
+
"type",
|
|
84
|
+
"uri",
|
|
85
|
+
"credential",
|
|
86
|
+
"warehouse",
|
|
87
|
+
"oauth2_server_uri",
|
|
88
|
+
"scope",
|
|
89
|
+
"warehouse_storage",
|
|
90
|
+
"warehouse_prefix",
|
|
91
|
+
# Glue and Unity keys stay allowed, as in Rust, so a typo reports the key first
|
|
92
|
+
"region",
|
|
93
|
+
"database",
|
|
94
|
+
"create_database_if_missing",
|
|
95
|
+
"allow_takeover",
|
|
96
|
+
"host",
|
|
97
|
+
"catalog",
|
|
98
|
+
"schema",
|
|
99
|
+
"token",
|
|
100
|
+
"create_schema_if_missing",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _storage(value: object, ctx: str) -> StorageDefinition:
|
|
105
|
+
hash = yaml_hash(value, ctx)
|
|
106
|
+
validate_known_keys(hash, ctx, _STORAGE_KEYS)
|
|
107
|
+
definition = StorageDefinition(
|
|
108
|
+
name=get_string(hash, "name", ctx),
|
|
109
|
+
type=get_string(hash, "type", ctx),
|
|
110
|
+
bucket=opt_string(hash, "bucket", ctx),
|
|
111
|
+
region=opt_string(hash, "region", ctx),
|
|
112
|
+
account=opt_string(hash, "account", ctx),
|
|
113
|
+
container=opt_string(hash, "container", ctx),
|
|
114
|
+
prefix=opt_string(hash, "prefix", ctx),
|
|
115
|
+
endpoint=opt_string(hash, "endpoint", ctx),
|
|
116
|
+
path_style_access=opt_bool(hash, "path_style_access", ctx),
|
|
117
|
+
)
|
|
118
|
+
if definition.type in ("adls", "gcs"):
|
|
119
|
+
raise _Unsupported(
|
|
120
|
+
f"{ctx} name={definition.name} type={definition.type} is not supported in 0.8"
|
|
121
|
+
)
|
|
122
|
+
return definition
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _catalog(value: object, ctx: str) -> CatalogDefinition:
|
|
126
|
+
hash = yaml_hash(value, ctx)
|
|
127
|
+
validate_known_keys(hash, ctx, _CATALOG_KEYS)
|
|
128
|
+
name = get_string(hash, "name", ctx)
|
|
129
|
+
catalog_type = get_string(hash, "type", ctx)
|
|
130
|
+
if catalog_type in ("glue", "unity"):
|
|
131
|
+
raise _Unsupported(
|
|
132
|
+
f"{ctx} name={name} type={catalog_type} is not supported in 0.8"
|
|
133
|
+
)
|
|
134
|
+
if catalog_type != "rest":
|
|
135
|
+
raise FloeConfigError(
|
|
136
|
+
f"{ctx} name={name} has unsupported type={catalog_type} (supported: rest)"
|
|
137
|
+
)
|
|
138
|
+
uri = get_string(hash, "uri", ctx)
|
|
139
|
+
credential = opt_string(hash, "credential", ctx)
|
|
140
|
+
return CatalogDefinition(
|
|
141
|
+
name=name,
|
|
142
|
+
uri=uri,
|
|
143
|
+
credential=None if credential is None else SecretStr(credential),
|
|
144
|
+
warehouse=opt_string(hash, "warehouse", ctx),
|
|
145
|
+
oauth2_server_uri=opt_string(hash, "oauth2_server_uri", ctx),
|
|
146
|
+
scope=opt_string(hash, "scope", ctx),
|
|
147
|
+
warehouse_storage=opt_string(hash, "warehouse_storage", ctx),
|
|
148
|
+
warehouse_prefix=opt_string(hash, "warehouse_prefix", ctx),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_storages(value: object, context: str = "storages") -> StoragesConfig:
|
|
153
|
+
hash, items = _items(value, context)
|
|
154
|
+
definitions: list[StorageDefinition] = []
|
|
155
|
+
for index, item in enumerate(items):
|
|
156
|
+
try:
|
|
157
|
+
definitions.append(_storage(item, f"{context}.definitions"))
|
|
158
|
+
except _Unsupported:
|
|
159
|
+
raise
|
|
160
|
+
except FloeConfigError as e:
|
|
161
|
+
raise FloeConfigError(f"{context}.definitions[{index}]: {e}") from e
|
|
162
|
+
return StoragesConfig(
|
|
163
|
+
default=opt_string(hash, "default", context), definitions=definitions
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_catalogs(value: object, context: str = "catalogs") -> CatalogsConfig:
|
|
168
|
+
hash, items = _items(value, context)
|
|
169
|
+
definitions: list[CatalogDefinition] = []
|
|
170
|
+
for index, item in enumerate(items):
|
|
171
|
+
try:
|
|
172
|
+
definitions.append(_catalog(item, f"{context}.definitions"))
|
|
173
|
+
except _Unsupported:
|
|
174
|
+
raise
|
|
175
|
+
except FloeConfigError as e:
|
|
176
|
+
raise FloeConfigError(f"{context}.definitions[{index}]: {e}") from e
|
|
177
|
+
return CatalogsConfig(
|
|
178
|
+
default=opt_string(hash, "default", context), definitions=definitions
|
|
179
|
+
)
|