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/parse.py ADDED
@@ -0,0 +1,140 @@
1
+ """`parse_config`, ported from `config/parse.rs` and `config/template.rs` (`apply_templates_with_vars`)."""
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+ from typing import Any, TypeVar
6
+
7
+ from pydantic import BaseModel, SecretStr
8
+
9
+ from floe.config.include import assemble_includes
10
+ from floe.config.project import RootConfig, parse_root
11
+ from floe.config.types import EntityConfig
12
+ from floe.config.vars import build_env_vars, replace_placeholders
13
+ from floe.config.yaml import load_single_doc, yaml_array, yaml_hash
14
+ from floe.errors import FloeConfigError
15
+
16
+ Vars = Mapping[str, str]
17
+ M = TypeVar("M", bound=BaseModel)
18
+
19
+
20
+ def _fill(model: M, names: tuple[str, ...], variables: Vars, label: str) -> M:
21
+ """Substitute placeholders in the `names` fields that are set; `label` prefixes the field in errors."""
22
+ update: dict[str, Any] = {}
23
+ for name in names:
24
+ value = getattr(model, name)
25
+ secret = isinstance(value, SecretStr)
26
+ if secret:
27
+ value = value.get_secret_value()
28
+ if value is not None:
29
+ filled = replace_placeholders(value, variables, label + name)
30
+ update[name] = SecretStr(filled) if secret else filled
31
+ return model.model_copy(update=update)
32
+
33
+
34
+ def _fill_entity(
35
+ entity: EntityConfig, variables: Vars, domains: Mapping[str, str | None]
36
+ ) -> EntityConfig:
37
+ context = dict(variables) | {"entity.name": entity.name}
38
+ if entity.domain is not None:
39
+ if entity.domain not in domains:
40
+ raise FloeConfigError(
41
+ f"entity.name={entity.name} references unknown domain {entity.domain}"
42
+ )
43
+ context.pop("domain.incoming_dir", None)
44
+ if (incoming_dir := domains[entity.domain]) is not None:
45
+ context["domain.incoming_dir"] = incoming_dir
46
+ context["domain.name"] = entity.domain
47
+
48
+ def sub(path: str, field: str) -> str:
49
+ return replace_placeholders(path, context, field, entity.name)
50
+
51
+ sink = entity.sink
52
+ accepted = sink.accepted.model_copy(
53
+ update={"path": sub(sink.accepted.path, "entities.sink.accepted.path")}
54
+ )
55
+ rejected, archive = sink.rejected, sink.archive
56
+ if rejected is not None:
57
+ rejected = rejected.model_copy(
58
+ update={"path": sub(rejected.path, "entities.sink.rejected.path")}
59
+ )
60
+ if archive is not None:
61
+ archive = archive.model_copy(
62
+ update={"path": sub(archive.path, "entities.sink.archive.path")}
63
+ )
64
+ return entity.model_copy(
65
+ update={
66
+ "source": entity.source.model_copy(
67
+ update={"path": sub(entity.source.path, "entities.source.path")}
68
+ ),
69
+ "sink": sink.model_copy(
70
+ update={"accepted": accepted, "rejected": rejected, "archive": archive}
71
+ ),
72
+ }
73
+ )
74
+
75
+
76
+ def _apply_templates(
77
+ config: RootConfig, config_dir: Path, profile_vars: Vars
78
+ ) -> RootConfig:
79
+ env = config.env
80
+ variables = build_env_vars(
81
+ config_dir, env.file if env else None, env.vars if env else {}, profile_vars
82
+ )
83
+ domains: list[Any] = []
84
+ lookup: dict[str, str | None] = {}
85
+ for domain in config.domains:
86
+ resolved = (
87
+ None
88
+ if domain.incoming_dir is None
89
+ else replace_placeholders(
90
+ domain.incoming_dir, variables, "domains.incoming_dir"
91
+ )
92
+ )
93
+ domains.append(domain.model_copy(update={"resolved_incoming_dir": resolved}))
94
+ if domain.name in lookup:
95
+ raise FloeConfigError(f"duplicate domain name {domain.name}")
96
+ lookup[domain.name] = resolved
97
+
98
+ report = _fill(config.report, ("path",), variables, "report.")
99
+ lineage = config.lineage
100
+ if lineage is not None:
101
+ lineage = _fill(
102
+ lineage,
103
+ ("url", "endpoint", "api_key", "dataset_namespace"),
104
+ variables,
105
+ "lineage.",
106
+ )
107
+ storages = config.storages
108
+ if storages is not None:
109
+ names = ("bucket", "region", "account", "container", "prefix", "endpoint")
110
+ definitions = [
111
+ _fill(d, names, variables, f"storages.definitions.{d.name}.")
112
+ for d in storages.definitions
113
+ ]
114
+ storages = storages.model_copy(update={"definitions": definitions})
115
+ return config.model_copy(
116
+ update={
117
+ "domains": domains,
118
+ "report": report,
119
+ "lineage": lineage,
120
+ "storages": storages,
121
+ "entities": [_fill_entity(e, variables, lookup) for e in config.entities],
122
+ }
123
+ )
124
+
125
+
126
+ def parse_config(
127
+ path: Path, profile_vars: Vars | None = None, extra_entity: object = None
128
+ ) -> RootConfig:
129
+ """Load a config and its includes, then apply the template pass.
130
+
131
+ `extra_entity` is appended to the entities, so `add-entity` validates an entity before writing it.
132
+ """
133
+ doc = load_single_doc(path)
134
+ config_files = [path]
135
+ origins = assemble_includes(doc, path.parent, config_files)
136
+ if extra_entity is not None:
137
+ entities = yaml_hash(doc, "root").setdefault("entities", [])
138
+ yaml_array(entities, "entities").append(extra_entity)
139
+ config = parse_root(doc, origins).model_copy(update={"config_files": config_files})
140
+ return _apply_templates(config, path.parent, profile_vars or {})
floe/config/profile.py ADDED
@@ -0,0 +1,381 @@
1
+ """Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import cast
7
+
8
+ from floe.config.project import LineageConfig, RootConfig, parse_lineage
9
+ from floe.config.storage import (
10
+ CatalogsConfig,
11
+ StoragesConfig,
12
+ parse_catalogs,
13
+ parse_storages,
14
+ )
15
+ from floe.config.validate import check_lineage
16
+ from floe.config.vars import string_map
17
+ from floe.config.yaml import (
18
+ load_yaml,
19
+ rust_debug,
20
+ validate_known_keys,
21
+ yaml_array,
22
+ yaml_hash,
23
+ yaml_string,
24
+ )
25
+ from floe.errors import FloeConfigError
26
+
27
+ Hash = dict[object, object]
28
+ Reader = Callable[[Hash, str, str], object]
29
+
30
+ API_VERSION = "floe/v1"
31
+ KIND = "EnvironmentProfile"
32
+ RUNNERS = ("local", "kubernetes_job", "databricks_job")
33
+ CONTRACT = {
34
+ "kubernetes_job": ("image", "namespace"),
35
+ "databricks_job": (
36
+ *("workspace_url", "existing_cluster_id", "config_uri", "python_file_uri"),
37
+ "auth.service_principal_oauth_ref",
38
+ ),
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ProfileConfig:
44
+ name: str
45
+ runner: dict[str, object] # `type` and every field, None when unset
46
+ variables: dict[str, str]
47
+ catalogs: CatalogsConfig | None
48
+ storages: StoragesConfig | None
49
+ lineage: LineageConfig | None
50
+ orchestration: dict[str, object] | None = None # its set fields only, as Rust
51
+
52
+
53
+ def _string(hash: Hash, key: str, ctx: str, required: bool = False) -> str | None:
54
+ if key not in hash:
55
+ if required:
56
+ raise FloeConfigError(f"{ctx}.{key} is required")
57
+ return None
58
+ return yaml_string(hash[key], f"{ctx}.{key}")
59
+
60
+
61
+ def _strings(hash: Hash, key: str, ctx: str) -> list[str] | None:
62
+ if key not in hash:
63
+ return None
64
+ items = yaml_array(hash[key], f"{ctx}.{key}")
65
+ return [yaml_string(item, f"{ctx}.{key}[]") for item in items]
66
+
67
+
68
+ def _u64(hash: Hash, key: str, ctx: str) -> int | None:
69
+ if key not in hash:
70
+ return None
71
+ value = hash[key]
72
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
73
+ raise FloeConfigError(f"{ctx}.{key} must be a non-negative integer")
74
+ return value
75
+
76
+
77
+ def _map(hash: Hash, key: str, ctx: str) -> dict[str, str] | None:
78
+ return string_map(hash[key], f"{ctx}.{key}") if key in hash else None
79
+
80
+
81
+ def _secrets(hash: Hash, key: str, ctx: str) -> list[dict[str, str | None]] | None:
82
+ if key not in hash:
83
+ return None
84
+ where = f"{ctx}.{key}[]"
85
+ secrets: list[dict[str, str | None]] = []
86
+ for item in yaml_array(hash[key], f"{ctx}.{key}"):
87
+ secret = yaml_hash(item, where)
88
+ validate_known_keys(secret, where, ("name", "secret_name", "key"))
89
+ fields = ("name", "secret_name", "key")
90
+ secrets.append({f: _string(secret, f, where, True) for f in fields})
91
+ return secrets
92
+
93
+
94
+ def _resources(
95
+ hash: Hash, key: str, ctx: str, per_domain: bool = False
96
+ ) -> dict[str, object] | None:
97
+ if key not in hash:
98
+ return None
99
+ where = f"{ctx}.{key}"
100
+ resources = yaml_hash(hash[key], where)
101
+ validate_known_keys(
102
+ resources, where, ("cpu", "memory_mb", "cpu_limit", "memory_limit_mb")
103
+ )
104
+ cpu = _string(resources, "cpu", where)
105
+ read: dict[str, object] = {
106
+ "cpu": cpu,
107
+ "memory_mb": _u64(resources, "memory_mb", where),
108
+ }
109
+ if per_domain and read["memory_mb"] == 0: # the manifest schema requires >= 1
110
+ raise FloeConfigError(f"{where}.memory_mb: must be >= 1")
111
+ memory_limit = _u64(resources, "memory_limit_mb", where)
112
+ if memory_limit == 0: # the schemas require >= 1
113
+ raise FloeConfigError(f"{where}.memory_limit_mb: must be >= 1")
114
+ limits = { # the limits are Python-only: carried only when set, so Rust manifests stay identical
115
+ "cpu_limit": _string(resources, "cpu_limit", where),
116
+ "memory_limit_mb": memory_limit,
117
+ }
118
+ return read | {k: v for k, v in limits.items() if v is not None}
119
+
120
+
121
+ def _domain_resources(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
122
+ if key not in hash:
123
+ return None
124
+ where = f"{ctx}.{key}"
125
+ by_domain = yaml_hash(hash[key], where)
126
+ validate_known_keys(
127
+ by_domain, where, by_domain.keys()
128
+ ) # every key must be a string
129
+ return {
130
+ str(domain): _resources(by_domain, str(domain), where, per_domain=True)
131
+ for domain in by_domain
132
+ }
133
+
134
+
135
+ def _job_scope(hash: Hash, key: str, ctx: str) -> str | None:
136
+ scope = _string(hash, key, ctx)
137
+ if scope not in (None, "entity", "domain"):
138
+ raise FloeConfigError(
139
+ f'{ctx}.{key}: expected "entity" or "domain", got "{scope}"'
140
+ )
141
+ return scope
142
+
143
+
144
+ def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
145
+ if key not in hash:
146
+ return None
147
+ where = f"{ctx}.{key}"
148
+ auth = yaml_hash(hash[key], where)
149
+ validate_known_keys(auth, where, ("service_principal_oauth_ref",))
150
+ ref = _string(auth, "service_principal_oauth_ref", where)
151
+ return {"service_principal_oauth_ref": ref}
152
+
153
+
154
+ # Rust reads the runner fields in this order, so the first malformed one is the one reported.
155
+ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
156
+ ("command", _string),
157
+ ("args", _strings),
158
+ ("timeout_seconds", _u64),
159
+ ("ttl_seconds_after_finished", _u64),
160
+ ("poll_interval_seconds", _u64),
161
+ ("secrets", _secrets),
162
+ ("image", _string),
163
+ ("namespace", _string),
164
+ ("service_account", _string),
165
+ ("resources", _resources),
166
+ ("env", _map),
167
+ ("workspace_url", _string),
168
+ ("existing_cluster_id", _string),
169
+ ("config_uri", _string),
170
+ ("python_file_uri", _string),
171
+ ("job_name", _string),
172
+ ("auth", _auth),
173
+ ("env_parameters", _map),
174
+ ("job_scope", _job_scope),
175
+ ("domain_resources", _domain_resources),
176
+ )
177
+
178
+
179
+ def _runner(value: object) -> dict[str, object]:
180
+ ctx = "profile.execution.runner"
181
+ hash = yaml_hash(value, ctx)
182
+ validate_known_keys(hash, ctx, ("type", *(name for name, _ in _RUNNER_FIELDS)))
183
+ read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
184
+ for name, reader in _RUNNER_FIELDS:
185
+ read[name] = reader(hash, name, ctx)
186
+ return read
187
+
188
+
189
+ def _orchestration(value: object) -> dict[str, object]:
190
+ ctx = "profile.execution.orchestration"
191
+ hash = yaml_hash(value, ctx)
192
+ validate_known_keys(hash, ctx, ("max_concurrent_entities", "strategy"))
193
+ max_concurrent = _u64(hash, "max_concurrent_entities", ctx)
194
+ if max_concurrent == 0:
195
+ raise FloeConfigError(f"{ctx}.max_concurrent_entities: must be >= 1")
196
+ strategy = _string(hash, "strategy", ctx)
197
+ if strategy not in (None, "sequential", "parallel"):
198
+ raise FloeConfigError(
199
+ f'{ctx}.strategy: expected "sequential" or "parallel", got "{strategy}"'
200
+ )
201
+ read = {"max_concurrent_entities": max_concurrent, "strategy": strategy}
202
+ return {k: v for k, v in read.items() if v is not None}
203
+
204
+
205
+ def _metadata(value: object) -> str:
206
+ ctx = "profile.metadata"
207
+ hash = yaml_hash(value, ctx)
208
+ validate_known_keys(hash, ctx, ("name", "description", "env", "tags"))
209
+ name = _string(hash, "name", ctx, True)
210
+ _string(hash, "description", ctx)
211
+ _string(hash, "env", ctx)
212
+ _strings(hash, "tags", ctx)
213
+ assert name is not None # `required` raised otherwise
214
+ return name
215
+
216
+
217
+ def _validation(value: object) -> None:
218
+ ctx = "profile.validation"
219
+ hash = yaml_hash(value, ctx)
220
+ validate_known_keys(hash, ctx, ("strict",))
221
+ if "strict" in hash and not isinstance(hash["strict"], bool):
222
+ raise FloeConfigError(f"{ctx}.strict must be a boolean")
223
+
224
+
225
+ def _parse_doc(doc: object) -> ProfileConfig:
226
+ root = yaml_hash(doc, "profile")
227
+ validate_known_keys(
228
+ root,
229
+ "profile",
230
+ (
231
+ *("apiVersion", "kind", "metadata", "execution", "variables"),
232
+ *("catalogs", "storages", "lineage", "validation"),
233
+ ),
234
+ )
235
+ for key, expected in (("apiVersion", API_VERSION), ("kind", KIND)):
236
+ got = _string(root, key, "profile", True)
237
+ if got != expected:
238
+ raise FloeConfigError(f'profile.{key}: expected "{expected}", got "{got}"')
239
+ if "metadata" not in root:
240
+ raise FloeConfigError("profile.metadata is required")
241
+ name = _metadata(root["metadata"])
242
+ runner: dict[str, object] = {}
243
+ orchestration = None
244
+ if "execution" in root:
245
+ ctx = "profile.execution"
246
+ execution = yaml_hash(root["execution"], ctx)
247
+ validate_known_keys(execution, ctx, ("runner", "orchestration"))
248
+ if "runner" not in execution:
249
+ raise FloeConfigError(f"{ctx}.runner is required")
250
+ runner = _runner(execution["runner"])
251
+ if "orchestration" in execution:
252
+ orchestration = _orchestration(execution["orchestration"])
253
+ variables = (
254
+ string_map(root["variables"], "profile.variables")
255
+ if "variables" in root
256
+ else {}
257
+ )
258
+ catalogs = (
259
+ parse_catalogs(root["catalogs"], "profile.catalogs")
260
+ if "catalogs" in root
261
+ else None
262
+ )
263
+ storages = parse_storages(root["storages"]) if "storages" in root else None
264
+ lineage = parse_lineage(root["lineage"]) if "lineage" in root else None
265
+ if "validation" in root:
266
+ _validation(root["validation"])
267
+ return ProfileConfig(
268
+ name, runner, variables, catalogs, storages, lineage, orchestration
269
+ )
270
+
271
+
272
+ def _malformed(value: str) -> str | None:
273
+ """The first `${}` or unclosed `${` in `value`; well-formed references are left to `resolve_vars`."""
274
+ rest = value
275
+ while (start := rest.find("${")) != -1:
276
+ rest = rest[start + 2 :]
277
+ end = rest.find("}")
278
+ if end == -1:
279
+ return f"${{... (unclosed placeholder in: {rust_debug(value)})}}"[:120]
280
+ if not rest[:end].strip():
281
+ return "${} (empty placeholder)"
282
+ rest = rest[end + 1 :]
283
+ return None
284
+
285
+
286
+ def _check_catalogs(catalogs: CatalogsConfig) -> None:
287
+ ctx = "profile.catalogs"
288
+ if not catalogs.definitions:
289
+ raise FloeConfigError(f"{ctx}.definitions must not be empty")
290
+ seen: set[str] = set()
291
+ for d in catalogs.definitions:
292
+ if not d.name.strip():
293
+ raise FloeConfigError(f"{ctx}.definitions.name must not be empty")
294
+ if d.name in seen:
295
+ raise FloeConfigError(f"{ctx}.definitions name={d.name} is duplicated")
296
+ seen.add(d.name)
297
+ if catalogs.default is not None and catalogs.default not in seen:
298
+ raise FloeConfigError(
299
+ f"{ctx}.default={catalogs.default} does not match any definition"
300
+ )
301
+
302
+
303
+ def _check_storages(storages: StoragesConfig) -> None:
304
+ ctx = "profile.storages"
305
+ if not storages.definitions:
306
+ raise FloeConfigError(f"{ctx}.definitions must not be empty")
307
+ seen: set[str] = set()
308
+ for d in storages.definitions:
309
+ # ponytail: Rust's adls/gcs requirements are unreachable here; both types are rejected at parse (D1)
310
+ if not d.name.strip():
311
+ raise FloeConfigError(f"{ctx}.definitions.name must not be empty")
312
+ if d.name in seen:
313
+ raise FloeConfigError(f"{ctx}.definitions name={d.name} is duplicated")
314
+ seen.add(d.name)
315
+ if d.type not in ("local", "s3", "adls", "gcs"):
316
+ raise FloeConfigError(
317
+ f"{ctx}.definitions name={d.name} type={d.type} is unsupported "
318
+ "(allowed: local, s3, adls, gcs)"
319
+ )
320
+ for field in ("bucket", "region") if d.type == "s3" else ():
321
+ if getattr(d, field) is None:
322
+ raise FloeConfigError(
323
+ f"{ctx}.definitions name={d.name} requires {field} for type s3"
324
+ )
325
+ if storages.default is None:
326
+ raise FloeConfigError(f"{ctx}.default is required when storages is set")
327
+ if storages.default not in seen:
328
+ raise FloeConfigError(
329
+ f"{ctx}.default={storages.default} does not match any definition"
330
+ )
331
+
332
+
333
+ def validate_profile(profile: ProfileConfig) -> None:
334
+ ctx = "profile.execution.runner"
335
+ if not profile.name.strip():
336
+ raise FloeConfigError("profile.metadata.name must not be empty")
337
+ if profile.runner:
338
+ kind = profile.runner["type"]
339
+ if kind not in RUNNERS:
340
+ raise FloeConfigError(
341
+ f'{ctx}.type: unknown runner "{kind}"; known runners: {", ".join(RUNNERS)}'
342
+ )
343
+ for field in CONTRACT.get(kind, ()):
344
+ key, _, sub = field.partition(".")
345
+ value = profile.runner.get(key)
346
+ if sub and isinstance(value, dict):
347
+ value = cast(dict[str, object], value).get(sub)
348
+ if not isinstance(value, str) or not value.strip():
349
+ raise FloeConfigError(f"{ctx}.{field} is required for {kind}")
350
+ for key, value in profile.variables.items():
351
+ if (placeholder := _malformed(value)) is not None:
352
+ raise FloeConfigError(
353
+ f'profile variable "{key}" contains malformed placeholder: {placeholder}'
354
+ )
355
+ if profile.catalogs is not None:
356
+ _check_catalogs(profile.catalogs)
357
+ if profile.storages is not None:
358
+ _check_storages(profile.storages)
359
+ if profile.lineage is not None:
360
+ check_lineage(profile.lineage, "profile.lineage")
361
+
362
+
363
+ def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
364
+ """The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
365
+ blocks = {
366
+ "catalogs": profile.catalogs,
367
+ "storages": profile.storages,
368
+ "lineage": profile.lineage,
369
+ }
370
+ return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
371
+
372
+
373
+ def parse_profile(path: Path) -> ProfileConfig:
374
+ docs = load_yaml(path)
375
+ if not docs:
376
+ raise FloeConfigError("profile YAML is empty")
377
+ if len(docs) > 1:
378
+ raise FloeConfigError("profile YAML contains multiple documents; expected one")
379
+ profile = _parse_doc(docs[0])
380
+ validate_profile(profile)
381
+ return profile