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/manifest.py ADDED
@@ -0,0 +1,503 @@
1
+ """Orchestrator manifest, ported from `manifest/builder.rs` and `manifest/model.rs`.
2
+
3
+ The manifest JSON is the contract (Dagster and Airflow consume it): key order is the order of the
4
+ Rust structs, and every value Rust builds with `serde_json::to_value` has its keys sorted.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import time
12
+ from dataclasses import asdict
13
+ from itertools import takewhile
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any, NamedTuple, cast
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from floe import __version__
20
+ from floe.config.profile import ProfileConfig
21
+ from floe.config.project import ReportConfig, RootConfig
22
+ from floe.config.storage import StoragesConfig
23
+ from floe.config.types import ArchiveTarget, EntityConfig, SinkTarget, SourceConfig
24
+ from floe.config.validate import FLOE_VERSION
25
+ from floe.errors import FloeConfigError, FloeError
26
+ from floe.pii import PiiColumn
27
+ from floe.run import select_entities
28
+ from floe.storage.paths import normalize_local_path
29
+
30
+ Json = dict[str, Any]
31
+ _REMOTE = ("s3://", "gs://", "gcs://", "abfs://", "abfss://", "az://")
32
+
33
+
34
+ def _sorted(value: object) -> Any:
35
+ """`serde_json::to_value`: objects come out key-sorted."""
36
+ if isinstance(value, BaseModel):
37
+ value = value.model_dump(mode="json")
38
+ if isinstance(value, dict):
39
+ return {k: _sorted(v) for k, v in sorted(cast(Json, value).items())}
40
+ if isinstance(value, list):
41
+ return [_sorted(v) for v in cast(list[object], value)]
42
+ return value
43
+
44
+
45
+ def _put(target: Json, **optional: object) -> Json:
46
+ """`skip_serializing_if = "Option::is_none"`."""
47
+ return target | {k: v for k, v in optional.items() if v is not None}
48
+
49
+
50
+ def _sha256(data: bytes) -> str:
51
+ return f"sha256:{hashlib.sha256(data).hexdigest()}"
52
+
53
+
54
+ def _manifest_id(config_uri: str, checksum: str | None) -> str:
55
+ h = 0xCBF29CE484222325
56
+ for byte in config_uri.encode() + b"\0" + (checksum or "").encode():
57
+ h = ((h ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
58
+ return f"mfv1-{h:016x}"
59
+
60
+
61
+ def _revision(manifest: Json) -> str:
62
+ """SHA-256 of the canonical content, without the volatile `generated_at_ts_ms` and itself."""
63
+ body = {
64
+ k: v
65
+ for k, v in manifest.items()
66
+ if k not in ("generated_at_ts_ms", "manifest_revision")
67
+ }
68
+ return _sha256(
69
+ json.dumps(_sorted(body), separators=(",", ":"), ensure_ascii=False).encode()
70
+ )
71
+
72
+
73
+ class Options(NamedTuple):
74
+ config_arg: str # `-c` as typed
75
+ deterministic: bool
76
+ manifest_name: str | None
77
+ default_domain: str | None
78
+ path_mode: str
79
+ runtime: str | None # None: detect
80
+ manifest_uri: str | None = (
81
+ None # replaces `{manifest_uri}` in `execution.base_args`
82
+ )
83
+ profile_arg: str | None = None # `-p` as typed
84
+ profile: ProfileConfig | None = None # its `execution` block shapes the runners
85
+
86
+
87
+ class _Context(NamedTuple):
88
+ config_dir: Path
89
+ storages: StoragesConfig | None
90
+ resolved_uri_paths: bool
91
+
92
+
93
+ def _s3_uri(bucket: str, prefix: str | None, raw: str) -> str | None:
94
+ """`resolve_s3_uri`: the bucket-qualified URI, or None where Rust errors."""
95
+ uri_bucket, _, uri_key = raw.removeprefix("s3://").partition("/")
96
+ if raw.startswith("s3://") and uri_bucket:
97
+ if uri_bucket != bucket:
98
+ return None
99
+ return f"s3://{bucket}/{uri_key}" if uri_key else f"s3://{bucket}"
100
+ key = "/".join(p for p in ((prefix or "").strip("/"), raw.lstrip("/")) if p)
101
+ return f"s3://{bucket}/{key}" if key else f"s3://{bucket}"
102
+
103
+
104
+ class _Target:
105
+ """`resolve_or_raw`: the resolved URI, else the raw path as typed."""
106
+
107
+ def __init__(
108
+ self,
109
+ ctx: _Context,
110
+ target: SourceConfig | SinkTarget | ArchiveTarget | ReportConfig,
111
+ ) -> None:
112
+ storages, self.raw = ctx.storages, target.path
113
+ name = target.storage or (storages.default if storages else "local")
114
+ definition = storages and next(
115
+ (d for d in storages.definitions if d.name == name), None
116
+ )
117
+ kind = definition.type if definition else "local" if not storages else None
118
+ uri = None
119
+ if kind == "local" and not self.raw.startswith(_REMOTE):
120
+ uri = f"local://{normalize_local_path(ctx.config_dir / self.raw)}"
121
+ elif kind == "s3" and definition and definition.bucket is not None:
122
+ uri = _s3_uri(definition.bucket, definition.prefix, self.raw)
123
+ self.resolved = uri is not None
124
+ self.storage = name if uri else target.storage or "local"
125
+ self.uri = uri or self.raw
126
+ # `--manifest-path-mode resolved-uri`: a resolved path is recorded as its URI, minus `local://`
127
+ self.path = (
128
+ self.uri.removeprefix("local://")
129
+ if ctx.resolved_uri_paths and uri
130
+ else self.raw
131
+ )
132
+
133
+ def json(self, fmt: str | None = None) -> Json:
134
+ fields = {"storage": self.storage, "uri": self.uri, "path": self.path}
135
+ return ({"format": fmt} if fmt else {}) | fields | {"resolved": self.resolved}
136
+
137
+
138
+ def _pii_key(entity: EntityConfig, column: PiiColumn) -> str | None:
139
+ """Only an exact `${VAR}` reference reaches a manifest (D20: no secrets). A literal hash key is
140
+ refused, not dropped: dropping it makes a manifest run hash with unsalted SHA-256 (#1006)."""
141
+ key = column.key
142
+ if key is None or re.fullmatch(r"\$\{[^{}]+\}", key):
143
+ return key
144
+ if column.strategy != "hash":
145
+ return None # `key` is ignored by every other strategy
146
+ raise FloeConfigError(
147
+ f"entity.name={entity.name} pii.columns[name={column.name}].key: a manifest never "
148
+ "carries secrets, so a literal hash key cannot be written to it; "
149
+ "set key to a ${ENV_VAR} reference"
150
+ )
151
+
152
+
153
+ def _sink(ctx: _Context, target: SinkTarget) -> Json:
154
+ return _put(
155
+ _Target(ctx, target).json(target.format),
156
+ options=_sorted(target.options),
157
+ partition_by=target.partition_by,
158
+ partition_spec=_sorted(target.partition_spec), # #911
159
+ merge=_sorted(target.merge),
160
+ iceberg=_sorted(target.iceberg),
161
+ )
162
+
163
+
164
+ def _entity(entity: EntityConfig, ctx: _Context, default_domain: str | None) -> Json:
165
+ source = _Target(ctx, entity.source)
166
+ sink, meta, schema, domain = (
167
+ entity.sink,
168
+ entity.metadata,
169
+ entity.schema_,
170
+ entity.domain if entity.domain is not None else default_domain,
171
+ )
172
+ accepted = _Target(ctx, sink.accepted)
173
+ rejected = sink.rejected and _Target(ctx, sink.rejected)
174
+ tags = {
175
+ k: v
176
+ for k, v in (
177
+ ("data_product", meta and meta.data_product),
178
+ ("domain", meta and meta.domain),
179
+ ("owner", meta and meta.owner),
180
+ )
181
+ if v is not None
182
+ }
183
+ pii = entity.pii and _sorted(
184
+ {
185
+ "columns": [
186
+ asdict(c) | {"key": _pii_key(entity, c)} for c in entity.pii.columns
187
+ ]
188
+ }
189
+ )
190
+ return _put(
191
+ {
192
+ "name": entity.id,
193
+ "domain": domain,
194
+ "group_name": domain if domain is not None else "default",
195
+ "asset_key": [domain if domain is not None else "default", entity.name],
196
+ "source_format": entity.source.format,
197
+ "accepted_sink_uri": accepted.uri,
198
+ "rejected_sink_uri": rejected and rejected.uri,
199
+ "tags": tags or None,
200
+ "source": _put(
201
+ source.json(entity.source.format),
202
+ ref=entity.source.source_ref,
203
+ resource=entity.source.resource,
204
+ )
205
+ | _put(
206
+ {
207
+ "cast_mode": entity.source.cast_mode,
208
+ "options": _sorted(entity.source.options),
209
+ },
210
+ partitioning=_sorted(entity.source.partitioning), # D28
211
+ ),
212
+ "sinks": {
213
+ "accepted": _sink(ctx, sink.accepted),
214
+ "rejected": sink.rejected and _sink(ctx, sink.rejected),
215
+ "archive": sink.archive and _Target(ctx, sink.archive).json(),
216
+ },
217
+ "runner": None,
218
+ "policy_severity": entity.policy.severity,
219
+ "write_mode": sink.write_mode,
220
+ "incremental_mode": entity.incremental_mode,
221
+ "schema": _put(
222
+ {
223
+ "columns": [
224
+ _put(
225
+ {"name": c.name, "column_type": c.column_type},
226
+ source=c.source,
227
+ nullable=c.nullable,
228
+ unique=c.unique,
229
+ width=c.width,
230
+ trim=c.trim,
231
+ )
232
+ for c in schema.columns
233
+ ],
234
+ "primary_key": schema.primary_key or [],
235
+ "unique_keys": schema.unique_keys or [],
236
+ },
237
+ normalize_columns=_sorted(schema.normalize_columns),
238
+ mismatch=_sorted(schema.mismatch),
239
+ schema_evolution=_sorted(schema.schema_evolution),
240
+ ),
241
+ },
242
+ pii=pii,
243
+ state_path=entity.state and entity.state.path,
244
+ )
245
+
246
+
247
+ def _execution(manifest_uri: str | None, profile: ProfileConfig | None) -> Json:
248
+ orchestration = profile and profile.orchestration
249
+ execution: Json = {
250
+ "entrypoint": "floe",
251
+ "base_args": ["run", "--manifest", manifest_uri or "{manifest_uri}"]
252
+ + ["--log-format", "json", "--quiet"],
253
+ "per_entity_args": ["--entities", "{entity_name}"],
254
+ "log_format": "json",
255
+ "result_contract": {
256
+ "run_finished_event": True,
257
+ "summary_uri_field": "summary_uri",
258
+ "exit_codes": {
259
+ "0": "success_or_rejected",
260
+ "1": "technical_failure",
261
+ "2": "aborted",
262
+ },
263
+ },
264
+ "defaults": {"env": {}, "workdir": None},
265
+ }
266
+ if orchestration is not None: # Rust skips it when the profile sets none
267
+ execution["orchestration"] = orchestration
268
+ return execution
269
+
270
+
271
+ _RUNNER_FIELDS = (
272
+ *("command", "args", "timeout_seconds", "ttl_seconds_after_finished"),
273
+ *("poll_interval_seconds", "secrets", "image", "namespace", "service_account"),
274
+ *("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
275
+ *("python_file_uri", "job_name", "auth", "env_parameters"),
276
+ )
277
+ _PYTHON_RUNNER_FIELDS = ("job_scope", "domain_resources") # carried only when set
278
+
279
+
280
+ # Rust `runners_contract`: the fields each profile runner type carries into the manifest
281
+ _PROFILE_RUNNER_FIELDS = {
282
+ "kubernetes_job": _RUNNER_FIELDS[:11],
283
+ "databricks_job": (*_RUNNER_FIELDS[:3], _RUNNER_FIELDS[4], *_RUNNER_FIELDS[11:]),
284
+ }
285
+
286
+
287
+ def _runners(profile: ProfileConfig | None) -> Json:
288
+ """The profile's `kubernetes_job` or `databricks_job` runner as `default`, else `local`."""
289
+ kind = profile and profile.runner.get("type")
290
+ if not isinstance(kind, str) or kind not in _PROFILE_RUNNER_FIELDS:
291
+ local = {"type": "local_process"} | dict.fromkeys(_RUNNER_FIELDS)
292
+ return {"default": "local", "definitions": {"local": local}}
293
+ assert profile is not None
294
+ keep = _PROFILE_RUNNER_FIELDS[kind]
295
+ runner: Json = {"type": kind}
296
+ for k in _RUNNER_FIELDS:
297
+ v = profile.runner.get(k) if k in keep else None
298
+ if k in ("env", "env_parameters") and isinstance(v, dict): # a BTreeMap in Rust
299
+ v = dict(sorted(cast(dict[str, str], v).items()))
300
+ runner[k] = v
301
+ if kind == "kubernetes_job":
302
+ for k in _PYTHON_RUNNER_FIELDS:
303
+ if (v := profile.runner.get(k)) is not None:
304
+ runner[k] = v
305
+ if kind == "databricks_job":
306
+ runner["job_name"] = runner["job_name"] or "floe-{domain}-{env}"
307
+ return {"default": "default", "definitions": {"default": runner}}
308
+
309
+
310
+ _OPTIONAL = (
311
+ *("work_root", "config_checksum", "config_files"),
312
+ *("profile_uri", "profile_checksum"),
313
+ )
314
+ _WORK_ROOT = "/work"
315
+
316
+
317
+ def _config_files(files: list[Path]) -> list[Json] | None:
318
+ """Every file of a multi-file config by path relative to the root's directory, else None."""
319
+ if len(files) < 2:
320
+ return None
321
+ root = files[0].parent.parts
322
+ entries: list[Json] = []
323
+ for file in files:
324
+ parts = file.parts
325
+ common = sum(1 for _ in takewhile(lambda p: p[0] == p[1], zip(parts, root)))
326
+ path = "/".join([".."] * (len(root) - common) + list(parts[common:]))
327
+ entries.append(
328
+ {"path": path, "sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
329
+ )
330
+ return sorted(entries, key=lambda e: e["path"])
331
+
332
+
333
+ def _blocks(root: RootConfig) -> Json:
334
+ """The effective `storages`, `catalogs` and `lineage`, serialized as Rust does (secrets in clear)."""
335
+ blocks: Json = {}
336
+ if root.storages:
337
+ blocks["storages"] = _sorted(
338
+ {
339
+ "default": root.storages.default,
340
+ "definitions": [
341
+ d.model_dump(exclude={"type"}) | {"fs_type": d.type}
342
+ for d in root.storages.definitions
343
+ ],
344
+ }
345
+ )
346
+ if root.catalogs:
347
+ blocks["catalogs"] = _sorted(
348
+ {
349
+ "default": root.catalogs.default,
350
+ "definitions": [
351
+ {
352
+ "name": c.name,
353
+ "type_config": {
354
+ "type": "rest",
355
+ "uri": c.uri,
356
+ "credential": c.credential
357
+ and c.credential.get_secret_value(),
358
+ "warehouse": c.warehouse,
359
+ "oauth2_server_uri": c.oauth2_server_uri,
360
+ "scope": c.scope,
361
+ },
362
+ "warehouse_storage": c.warehouse_storage,
363
+ "warehouse_prefix": c.warehouse_prefix,
364
+ }
365
+ for c in root.catalogs.definitions
366
+ ],
367
+ }
368
+ )
369
+ if root.lineage:
370
+ key = root.lineage.api_key
371
+ blocks["lineage"] = _sorted(
372
+ root.lineage.model_dump(exclude={"api_key"})
373
+ | {"api_key": key and key.get_secret_value()}
374
+ )
375
+ return blocks
376
+
377
+
378
+ def _sources(entities: list[Json]) -> list[Json] | None:
379
+ """D20: the named sources' index, derived from the entities that use them."""
380
+ index: dict[str, Json] = {}
381
+ for entity in entities:
382
+ source = entity["source"]
383
+ if (ref := source.get("ref")) is None:
384
+ continue
385
+ # ponytail: an entity overlay can change a named source's format; the first entity's wins
386
+ entry = index.setdefault(
387
+ ref, {"name": ref, "format": source["format"], "uri": source["uri"]}
388
+ )
389
+ pairs = zip(entry["uri"].split("/"), source["uri"].split("/"))
390
+ common = takewhile(lambda p: p[0] == p[1], pairs)
391
+ entry["uri"] = "/".join(a for a, _ in common)
392
+ entry.setdefault("entities", []).append(entity["name"])
393
+ for (
394
+ entry
395
+ ) in index.values(): # consumers on different storages or buckets share no location
396
+ if entry["uri"].rstrip("/").endswith(":") or not entry["uri"]:
397
+ entry["uri"] = None
398
+ return sorted(index.values(), key=lambda s: s["name"]) or None
399
+
400
+
401
+ def detect_runtime() -> str:
402
+ """`FLOE_RUNTIME`, else a container (`/.dockerenv` or cwd `/work`) is `image`, else `cli`."""
403
+ forced = os.environ.get("FLOE_RUNTIME", "").strip().lower()
404
+ if forced in ("image", "cli"):
405
+ return forced
406
+ in_container = Path("/.dockerenv").exists() or Path.cwd() == Path(_WORK_ROOT)
407
+ return "image" if in_container else "cli"
408
+
409
+
410
+ def _config_uri(typed: str, canonical: Path, work_root: str | None) -> str:
411
+ """`cli`: the canonical host path. `image`: the path as typed, rebased under the work-root."""
412
+ if work_root is None:
413
+ return f"local://{canonical}"
414
+ parts = ["" if p.startswith("/") else p for p in PurePosixPath(typed).parts]
415
+ rel = "/".join(parts) or "."
416
+ return (
417
+ f"local://{rel}"
418
+ if rel.startswith("/")
419
+ else f"local://{work_root.rstrip('/')}/{rel}"
420
+ )
421
+
422
+
423
+ def build_manifest(
424
+ root: RootConfig, config_path: Path, options: Options, selectors: list[str]
425
+ ) -> Json:
426
+ if options.path_mode not in ("default", "resolved-uri"):
427
+ raise FloeError(
428
+ f"unknown --manifest-path-mode value '{options.path_mode}'; expected 'default' or 'resolved-uri'"
429
+ )
430
+ runtime = options.runtime or detect_runtime()
431
+ work_root = None
432
+ if runtime == "image":
433
+ work_root = os.environ.get("FLOE_WORK_ROOT", "")
434
+ work_root = (work_root if work_root.strip() else _WORK_ROOT).replace("\\", "/")
435
+ config_uri = _config_uri(options.config_arg, config_path, work_root)
436
+ ctx = _Context(
437
+ # D20: in `image` runtime local paths resolve beside `config_uri`, never on the host
438
+ Path(config_uri.removeprefix("local://")).parent
439
+ if work_root is not None
440
+ else normalize_local_path(config_path.parent),
441
+ root.storages,
442
+ options.path_mode == "resolved-uri",
443
+ )
444
+ files = _config_files(root.config_files)
445
+ checksum = (
446
+ _sha256("".join(f"{f['path']}\0{f['sha256']}\n" for f in files).encode())
447
+ if files
448
+ else _sha256(config_path.read_bytes())
449
+ if len(root.config_files) == 1
450
+ else None
451
+ )
452
+ report = _Target(ctx, root.report)
453
+ profile_uri = profile_checksum = None
454
+ if options.profile_arg is not None: # recorded like `config_uri` and its checksum
455
+ profile = Path(os.path.realpath(options.profile_arg))
456
+ profile_uri = _config_uri(options.profile_arg, profile, work_root)
457
+ profile_checksum = _sha256(profile.read_bytes())
458
+ manifest: Json = _put(
459
+ {
460
+ "generated_at_ts_ms": 0,
461
+ "floe_version": __version__,
462
+ "spec_version": f"{FLOE_VERSION[0]}.{FLOE_VERSION[1]}", # D22
463
+ },
464
+ manifest_name=options.manifest_name,
465
+ )
466
+ manifest |= {
467
+ "manifest_id": _manifest_id(config_uri, checksum),
468
+ "manifest_revision": None,
469
+ "runtime_env": runtime,
470
+ "work_root": work_root,
471
+ "config_uri": config_uri,
472
+ "config_checksum": checksum,
473
+ "config_files": files,
474
+ "profile_uri": profile_uri,
475
+ "profile_checksum": profile_checksum,
476
+ "report_base_uri": report.uri,
477
+ "domains": [
478
+ _put(
479
+ {"name": d.name},
480
+ incoming_dir=d.resolved_incoming_dir or d.incoming_dir,
481
+ )
482
+ for d in root.domains
483
+ ],
484
+ "execution": _execution(options.manifest_uri, options.profile),
485
+ "runners": _runners(options.profile),
486
+ "entities": [
487
+ _entity(e, ctx, options.default_domain)
488
+ for e in sorted(select_entities(root, selectors), key=lambda e: e.id)
489
+ ],
490
+ }
491
+ manifest |= _blocks(root)
492
+ manifest = _put(
493
+ manifest,
494
+ metadata=root.metadata and root.metadata.model_dump(exclude_none=True),
495
+ sources=_sources(manifest["entities"]),
496
+ )
497
+ manifest = {
498
+ k: v for k, v in manifest.items() if k not in _OPTIONAL or v is not None
499
+ }
500
+ if not options.deterministic:
501
+ manifest["generated_at_ts_ms"] = time.time_ns() // 1_000_000
502
+ manifest["manifest_revision"] = _revision(manifest)
503
+ return manifest