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/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