floe-python 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/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
+ )