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/report/entity.py ADDED
@@ -0,0 +1,245 @@
1
+ """Entity `run.json` (Rust: `report/{entity,build,mod}.rs`). Dicts in Rust's field order, serialised like serde."""
2
+
3
+ import json
4
+ from collections.abc import Mapping, Sequence
5
+ from typing import Any
6
+
7
+ from floe.checks.mismatch import MismatchOutcome
8
+ from floe.checks.normalize import resolve_strategy, source_names
9
+ from floe.checks.rows import RowCheck
10
+ from floe.checks.unique import UniqueResult
11
+ from floe.config.types import EntityConfig, SchemaEvolutionConfig, WriteMode
12
+ from floe.storage.inputs import ResolvedInputs
13
+ from floe.write.outputs import AcceptedWrite
14
+
15
+ MAX_RESOLVED_INPUTS = 50
16
+ _RULES = ("not_null", "cast_error", "unique", "schema_error")
17
+ # `serde_json::Map` is a BTreeMap, so the echoed options and metadata come out key-sorted.
18
+ _OPTION_KEYS = ("header", "separator", "encoding", "null_values", "recursive", "glob",
19
+ "json_mode", "row_tag", "namespace", "value_tag") # fmt: skip
20
+ _META_KEYS = ("data_product", "domain", "owner", "description", "tags")
21
+ _UNIQUE_EFFECT = {
22
+ "warn": ("warn", "warning"),
23
+ "reject": ("reject_rows", "rows_rejected"),
24
+ "abort": ("abort_run", "run_aborted"),
25
+ }
26
+
27
+
28
+ def serialize(report: dict[str, Any]) -> str:
29
+ """`serde_json::to_string_pretty`: two-space indent, raw UTF-8, no trailing newline."""
30
+ return json.dumps(report, indent=2, ensure_ascii=False)
31
+
32
+
33
+ def summarize_rules(
34
+ checks: Sequence[RowCheck], entity: EntityConfig
35
+ ) -> list[dict[str, Any]]:
36
+ """Rust's `summarize_validation_exprs`: per rule (fixed order), per column (sorted) violation counts."""
37
+ columns = entity.schema_.columns
38
+ names = source_names(columns, resolve_strategy(entity.schema_))
39
+ types = {n: c.column_type for n, c in zip(names, columns, strict=True)}
40
+ sources = {n: c.source for n, c in zip(names, columns, strict=True) if c.source}
41
+ rules: list[dict[str, Any]] = []
42
+ for rule in _RULES:
43
+ counts: dict[str, int] = {}
44
+ for c in checks:
45
+ if c.rule == rule or (rule == "schema_error" and c.rule not in _RULES):
46
+ counts[c.column] = counts.get(c.column, 0) + int(c.mask.sum())
47
+ cols: list[dict[str, Any]] = []
48
+ for name, n in sorted(counts.items()):
49
+ if n:
50
+ col: dict[str, Any] = {"column": name, "violations": n}
51
+ if rule == "cast_error" and name in types:
52
+ col["target_type"] = types[name]
53
+ if name in sources:
54
+ col["source"] = sources[name]
55
+ cols.append(col)
56
+ if cols:
57
+ total = sum(c["violations"] for c in cols)
58
+ rules.append(
59
+ {"rule": rule, "severity": entity.policy.severity, "violations": total, "columns": cols}
60
+ ) # fmt: skip
61
+ return rules
62
+
63
+
64
+ def file_report(
65
+ entity: EntityConfig,
66
+ input_file: str,
67
+ row_count: int,
68
+ rejected_count: int,
69
+ checks: Sequence[RowCheck],
70
+ mismatch: MismatchOutcome,
71
+ rejected_path: str | None = None,
72
+ archived_path: str | None = None,
73
+ errors_path: str | None = None,
74
+ forced_rejects: int = 0,
75
+ ) -> dict[str, Any]:
76
+ """One `files[]` entry. `rejected_count` is the split's; severity decides status and counts (Rust `validate_split`)."""
77
+ # a rejected/aborted mismatch decides the file before any row check runs (Rust `precheck.rs`)
78
+ precheck = mismatch.aborted or mismatch.rejected
79
+ rules = [] if precheck else summarize_rules(checks, entity)
80
+ violations = sum(r["violations"] for r in rules)
81
+ severity = entity.policy.severity
82
+ if precheck:
83
+ status = "aborted" if mismatch.aborted else "rejected"
84
+ accepted, rejected = 0, row_count
85
+ elif (
86
+ severity == "warn" or not violations
87
+ ): # warn still rejects merge-key duplicates
88
+ status, accepted, rejected = (
89
+ "success",
90
+ row_count - forced_rejects,
91
+ forced_rejects,
92
+ )
93
+ elif severity == "reject":
94
+ status, accepted, rejected = (
95
+ "rejected",
96
+ row_count - rejected_count,
97
+ rejected_count,
98
+ )
99
+ else:
100
+ status, accepted, rejected = "aborted", 0, row_count
101
+ warnings = violations if severity == "warn" else 0
102
+ return {
103
+ "input_file": input_file,
104
+ "status": status,
105
+ "row_count": row_count,
106
+ "accepted_count": accepted,
107
+ "rejected_count": rejected,
108
+ "mismatch": mismatch.report,
109
+ "output": {
110
+ "accepted_path": None,
111
+ "rejected_path": rejected_path,
112
+ "errors_path": errors_path,
113
+ "archived_path": archived_path,
114
+ },
115
+ "validation": {
116
+ "errors": violations - warnings + mismatch.errors,
117
+ "warnings": warnings + mismatch.warnings,
118
+ "rules": rules,
119
+ },
120
+ } # fmt: skip
121
+
122
+
123
+ def _unique_report(severity: str, r: UniqueResult) -> dict[str, Any]:
124
+ action, effect = (
125
+ _UNIQUE_EFFECT[severity] if r.duplicates_count else ("none", "none")
126
+ )
127
+ out: dict[str, Any] = {"columns": r.columns, "duplicates_count": r.duplicates_count}
128
+ if batch := r.duplicates_count - r.target_duplicates_count:
129
+ out["batch_duplicates_count"] = batch
130
+ if r.target_duplicates_count:
131
+ out["target_duplicates_count"] = r.target_duplicates_count
132
+ out |= {
133
+ "affected_rows_count": r.duplicates_count,
134
+ "action": action,
135
+ "status_effect": effect,
136
+ }
137
+ if r.samples:
138
+ out["samples"] = [
139
+ {"values": dict(sorted(s.values.items())), "count": s.count}
140
+ for s in r.samples
141
+ ]
142
+ return out # fmt: skip
143
+
144
+
145
+ def build_report(
146
+ entity: EntityConfig,
147
+ version: str,
148
+ inputs: ResolvedInputs,
149
+ files: list[dict[str, Any]],
150
+ write: AcceptedWrite,
151
+ *,
152
+ source_uri: str,
153
+ accepted_uri: str,
154
+ rejected_uri: str | None,
155
+ unique: Sequence[UniqueResult] = (),
156
+ write_mode: WriteMode | None = None,
157
+ table_root_uri: str | None = None,
158
+ table: Mapping[str, Any] | None = None,
159
+ merge: Mapping[str, Any] | None = None,
160
+ ) -> dict[str, Any]:
161
+ """The entity's `run.json`; `files` are the `file_report`s, in input order.
162
+
163
+ `table` holds a table sink's fields (Iceberg: version, snapshot, catalog), whose metrics are reported even for no files.
164
+ """
165
+ src, sink, severity = entity.source, entity.sink, entity.policy.severity
166
+ if write.files_written:
167
+ for f in files:
168
+ if f["status"] != "skipped":
169
+ f["output"]["accepted_path"] = accepted_uri
170
+ totals: dict[str, int] = {
171
+ "files_total": len(files),
172
+ "files_skipped": sum(f["status"] == "skipped" for f in files),
173
+ "rows_total": sum(f["row_count"] for f in files),
174
+ "accepted_total": sum(f["accepted_count"] for f in files),
175
+ "rejected_total": sum(f["rejected_count"] for f in files),
176
+ "warnings_total": sum(f["validation"]["warnings"] for f in files),
177
+ "errors_total": sum(f["validation"]["errors"] for f in files),
178
+ }
179
+ entity_echo: dict[str, Any] = {"name": entity.id}
180
+ if entity.metadata:
181
+ entity_echo["metadata"] = {
182
+ k: v
183
+ for k in sorted(_META_KEYS)
184
+ if (v := getattr(entity.metadata, k)) is not None
185
+ }
186
+ sink_echo: dict[str, Any] = {
187
+ "accepted": {"format": sink.accepted.format, "path": accepted_uri}
188
+ }
189
+ if sink.rejected:
190
+ sink_echo["rejected"] = {
191
+ "format": sink.rejected.format,
192
+ "path": rejected_uri or sink.rejected.path,
193
+ }
194
+ archive: dict[str, Any] = {
195
+ "enabled": (entity.incremental_mode in ("none", "archive"))
196
+ and sink.archive is not None
197
+ }
198
+ if sink.archive:
199
+ archive["path"] = sink.archive.path
200
+ sink_echo["archive"] = archive
201
+ evolution = entity.schema_.schema_evolution or SchemaEvolutionConfig()
202
+ report: dict[str, Any] = {
203
+ "spec_version": version,
204
+ "entity": entity_echo,
205
+ "source": {
206
+ "format": src.format,
207
+ "path": source_uri,
208
+ "options": {
209
+ k: v for k in sorted(_OPTION_KEYS) if (v := getattr(src.options, k)) is not None
210
+ },
211
+ "cast_mode": src.cast_mode,
212
+ "read_plan": "raw_and_typed",
213
+ "resolved_inputs": {
214
+ "mode": inputs.mode,
215
+ "file_count": len(inputs.listed),
216
+ "files": inputs.listed[:MAX_RESOLVED_INPUTS],
217
+ },
218
+ },
219
+ "sink": sink_echo,
220
+ "policy": {"severity": severity},
221
+ "accepted_output": {
222
+ "path": accepted_uri,
223
+ "table_root_uri": table_root_uri or accepted_uri,
224
+ "write_mode": write_mode or sink.write_mode,
225
+ "accepted_rows": totals["accepted_total"],
226
+ "files_written": write.files_written,
227
+ "parts_written": write.files_written,
228
+ **({"part_files": write.part_files} if write.part_files else {}),
229
+ **(table or {}),
230
+ # Rust's default output (nothing written) has no byte metrics
231
+ "total_bytes_written": write.total_bytes_written if write.files_written or table else None,
232
+ "avg_file_size_mb": write.avg_file_size_mb if write.files_written or table else None,
233
+ "small_files_count": write.small_files_count if write.files_written or table else None,
234
+ **(merge or {}),
235
+ },
236
+ "schema_evolution": {
237
+ "enabled": False,
238
+ "mode": evolution.mode,
239
+ "applied": False,
240
+ "incompatible_changes_detected": False,
241
+ },
242
+ } # fmt: skip
243
+ if unique:
244
+ report["unique_constraints"] = [_unique_report(severity, r) for r in unique]
245
+ return report | {"results": totals, "files": files}
floe/report/summary.py ADDED
@@ -0,0 +1,114 @@
1
+ """Run `run.summary.json`, run status and exit code, report layout (Rust: `report/{mod,output}.rs`, `run/mod.rs`)."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from floe import __version__
7
+ from floe.config.project import ProjectMetadata
8
+ from floe.config.types import EntityConfig
9
+ from floe.report.entity import serialize
10
+ from floe.storage.types import StorageClient
11
+
12
+ _TOTALS = ("files_total", "files_skipped", "rows_total", "accepted_total",
13
+ "rejected_total", "warnings_total", "errors_total") # fmt: skip
14
+
15
+
16
+ def id_path(entity: EntityConfig) -> str:
17
+ """Report sub-directory of an entity: `<domain>/<name>` when the id is qualified."""
18
+ if entity.domain and entity.id != entity.name:
19
+ return f"{entity.domain}/{entity.name}"
20
+ return entity.id
21
+
22
+
23
+ def run_domain(entities: Sequence[EntityConfig]) -> str | None:
24
+ """D37: the domain every entity of the run belongs to, else None; a pre-0.3 domain that is no safe directory name (`..`, `a/b`) is None too."""
25
+ domains = {e.domain for e in entities}
26
+ domain = domains.pop() if len(domains) == 1 else None
27
+ return domain if domain and not any(c in domain for c in "./\\") else None
28
+
29
+
30
+ def run_dir(report_path: str, run_id: str, domain: str | None) -> str:
31
+ """D37: `<report>/<domain>/run_<id>` for a single-domain run, else `<report>/run_<id>`."""
32
+ return "/".join([report_path.rstrip("/"), *([domain] if domain else []), f"run_{run_id}"]) # fmt: skip
33
+
34
+
35
+ def run_outcome(file_statuses: Sequence[str], warnings: int = 0) -> tuple[str, int]:
36
+ """Rust's `compute_run_outcome` then `upgrade_status_for_warnings`: (status, exit code)."""
37
+ if "failed" in file_statuses:
38
+ return "failed", 1
39
+ if "aborted" in file_statuses:
40
+ return "aborted", 2
41
+ if "rejected" in file_statuses:
42
+ return "rejected", 0
43
+ return ("success_with_warnings" if warnings else "success"), 0
44
+
45
+
46
+ def build_summary(
47
+ version: str,
48
+ reports: Sequence[tuple[str, dict[str, Any]]],
49
+ *,
50
+ run_id: str,
51
+ started_at: str,
52
+ finished_at: str,
53
+ duration_ms: int,
54
+ config_path: str,
55
+ report_path: str,
56
+ metadata: ProjectMetadata | None = None,
57
+ domain: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ """`reports` are the entity `run.json` dicts in run order, each with its `id_path`; `domain` is the run's one domain (D37)."""
60
+ run_path = run_dir(report_path, run_id, domain)
61
+ totals = {k: sum(r["results"][k] for _, r in reports) for k in _TOTALS}
62
+ statuses = [f["status"] for _, r in reports for f in r["files"]]
63
+ status, exit_code = run_outcome(statuses, totals["warnings_total"])
64
+ entities = [
65
+ {
66
+ "name": r["entity"]["name"],
67
+ "status": run_outcome(
68
+ [f["status"] for f in r["files"]], r["results"]["warnings_total"]
69
+ )[0],
70
+ "results": r["results"],
71
+ "report_file": f"{run_path}/{path}/run.json",
72
+ }
73
+ for path, r in reports
74
+ ]
75
+ return {
76
+ "spec_version": version,
77
+ "tool": {"name": "floe", "version": __version__},
78
+ "run": {
79
+ "run_id": run_id,
80
+ }
81
+ | ({"domain": domain} if domain else {})
82
+ | {
83
+ "started_at": started_at,
84
+ "finished_at": finished_at,
85
+ "duration_ms": duration_ms,
86
+ "status": status,
87
+ "exit_code": exit_code,
88
+ },
89
+ "config": {"path": config_path, "version": version}
90
+ | ({"metadata": metadata.model_dump(exclude_none=True)} if metadata else {}),
91
+ "report": {"path": report_path, "report_file": f"{run_path}/run.summary.json"},
92
+ "results": totals,
93
+ "entities": entities,
94
+ }
95
+
96
+
97
+ def write_reports(
98
+ summary: dict[str, Any],
99
+ reports: Sequence[tuple[str, dict[str, Any]]],
100
+ client: StorageClient,
101
+ ) -> None:
102
+ """Write `<run_dir>/<id_path>/run.json` per entity, then `run.summary.json` (overwriting, as Rust)."""
103
+ summary_file = summary["report"]["report_file"]
104
+ run_dir = summary_file.removesuffix("/run.summary.json")
105
+ for path, report in reports:
106
+ write_entity_report(run_dir, path, report, client)
107
+ client.write(summary_file, serialize(summary).encode())
108
+
109
+
110
+ def write_entity_report(
111
+ run_dir: str, path: str, report: dict[str, Any], client: StorageClient
112
+ ) -> None:
113
+ """One entity's `run.json`, written as the entity finishes (Rust)."""
114
+ client.write(f"{run_dir}/{path}/run.json", serialize(report).encode())
floe/run/__init__.py ADDED
@@ -0,0 +1,175 @@
1
+ """A run: entity selection, run context, sequential or parallel entities, dry run, full refresh, events (Rust: `run/mod.rs`, `run/context.rs`)."""
2
+
3
+ import time
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from floe.checks.rows import Formatter
11
+ from floe.config.project import RootConfig
12
+ from floe.config.types import EntityConfig
13
+ from floe.errors import FloeConfigError, FloeError
14
+ from floe.report.summary import (
15
+ build_summary,
16
+ id_path,
17
+ run_dir,
18
+ run_domain,
19
+ run_outcome,
20
+ write_entity_report,
21
+ write_reports,
22
+ )
23
+ from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
24
+ from floe.run.events import Emitter, Observer
25
+ from floe.storage.inputs import ResolvedInputs
26
+ from floe.storage.paths import normalize_local_path
27
+ from floe.storage.resolve import resolve_path, storage_client
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RunOutcome:
32
+ run_id: str
33
+ summary: dict[str, Any]
34
+ dry_run: dict[str, list[str]] | None # entity id -> scanned files
35
+ report_base_path: str
36
+ run_dir: str # D37: `<report>/<domain>/run_<id>` for a single-domain run
37
+ entity_reports: list[dict[str, Any]]
38
+
39
+
40
+ def select_entities(root: RootConfig, selectors: Sequence[str]) -> list[EntityConfig]:
41
+ """Config order; a selector is an entity id, else a name (Rust `select_entities`)."""
42
+ if not selectors:
43
+ return list(root.entities)
44
+ picked: set[int] = set()
45
+ missing: list[str] = []
46
+ for sel in selectors:
47
+ hits = [i for i, e in enumerate(root.entities) if e.id == sel] or [
48
+ i for i, e in enumerate(root.entities) if e.name == sel
49
+ ]
50
+ if len(hits) > 1:
51
+ ids = ", ".join(root.entities[i].id for i in hits)
52
+ raise FloeConfigError(f"entity {sel} is ambiguous; use one of: {ids}")
53
+ if hits:
54
+ picked.add(hits[0])
55
+ else:
56
+ missing.append(sel)
57
+ if missing:
58
+ raise FloeConfigError(f"entities not found: {', '.join(missing)}")
59
+ return [e for i, e in enumerate(root.entities) if i in picked]
60
+
61
+
62
+ def _rfc3339() -> str:
63
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
64
+
65
+
66
+ def _refresh(entity: EntityConfig) -> EntityConfig:
67
+ """Full refresh: an append sink overwrites; merge sinks cannot refresh."""
68
+ mode = entity.sink.write_mode
69
+ if mode in ("merge_scd1", "merge_scd2"):
70
+ raise FloeConfigError(f"entity '{entity.name}': --full-refresh is not supported with write_mode '{mode}'") # fmt: skip
71
+ if mode != "append":
72
+ return entity
73
+ return entity.model_copy(update={"sink": entity.sink.model_copy(update={"write_mode": "overwrite"})}) # fmt: skip
74
+
75
+
76
+ def _finished(emit: Emitter, name: str, report: dict[str, Any] | None) -> None:
77
+ """`entity_finished`; a failed entity (no report) counts zeros."""
78
+ keys = ("files", "files_skipped", "rows", "accepted", "rejected", "warnings", "errors") # fmt: skip
79
+ results: dict[str, int] = report["results"] if report else {}
80
+ totals = {k: results.get(k if k == "files_skipped" else f"{k}_total", 0) for k in keys} # fmt: skip
81
+ status = "failed"
82
+ if report:
83
+ status = run_outcome([f["status"] for f in report["files"]], totals["warnings"])[0] # fmt: skip
84
+ emit("entity_finished", name=name, status=status, **totals)
85
+
86
+
87
+ def run(
88
+ path: Path,
89
+ root: RootConfig,
90
+ *,
91
+ run_id: str | None = None,
92
+ entities: Sequence[str] = (),
93
+ dry_run: bool = False,
94
+ full_refresh: bool = False,
95
+ observer: Observer | None = None,
96
+ config_uri: str | None = None,
97
+ ) -> RunOutcome:
98
+ """Run the selected entities in config order, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust."""
99
+ started, timer = _rfc3339(), time.monotonic()
100
+ selected = select_entities(root, entities)
101
+ if full_refresh:
102
+ selected = [_refresh(e) for e in selected]
103
+ # a symlinked manifest or config: paths are relative to its target
104
+ config_dir = normalize_local_path(path.resolve().parent)
105
+ # a remote manifest is reported as typed
106
+ config = config_uri or str(normalize_local_path(path))
107
+ report_uri, report_storage = resolve_path(root.storages, config_dir, root.report.storage, root.report.path, "report.storage") # fmt: skip
108
+ report_path = report_uri.removeprefix("local://")
109
+ run_id = run_id or started.replace(":", "-")
110
+ domain = run_domain(selected)
111
+ run_path = run_dir(report_path, run_id, domain)
112
+
113
+ def inputs(e: EntityConfig) -> ResolvedInputs:
114
+ return list_source(
115
+ e, config_dir, root.storages, *resolve_source(e, config_dir, root.storages), full_refresh
116
+ ) # fmt: skip
117
+
118
+ if dry_run: # Rust: no events, no reports; the inputs are only listed
119
+ scanned = {e.id: [f.source_uri for f in inputs(e).files] for e in selected}
120
+ summary = build_summary(
121
+ root.version, [], run_id=run_id, started_at=started, finished_at=_rfc3339(),
122
+ duration_ms=0, config_path=config, report_path=report_path,
123
+ metadata=root.metadata, domain=domain,
124
+ ) # fmt: skip
125
+ summary["report"]["report_file"] = "disabled (dry-run)"
126
+ return RunOutcome(run_id, summary, scanned, report_path, run_path, [])
127
+ emit = Emitter(observer, run_id)
128
+ formatter = cast(
129
+ Formatter, root.report.formatter or "json"
130
+ ) # validated with the config
131
+
132
+ def one(entity: EntityConfig) -> EntityRun:
133
+ emit("entity_started", name=entity.id)
134
+ try:
135
+ return run_entity(
136
+ entity, config_dir, root.version, formatter, root.storages, emit, full_refresh,
137
+ root.catalogs, listed.get(entity.id),
138
+ ) # fmt: skip
139
+ except FloeError:
140
+ _finished(emit, entity.id, None)
141
+ raise
142
+
143
+ emit("run_started", config=config, report_base=report_path)
144
+ # Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
145
+ listed: dict[str, ResolvedInputs] = {}
146
+ for e in selected:
147
+ # D24: a full refresh may rebuild from the archive alone, so run_entity decides
148
+ archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
149
+ if not archive:
150
+ listed[e.id] = inputs(e)
151
+ reports: list[tuple[str, dict[str, Any]]] = []
152
+ client = storage_client(report_storage)
153
+ # D36: a policy abort fails only its entity; a FloeError still stops the run
154
+ for e in selected:
155
+ report = one(e).report
156
+ reports.append((id_path(e), report))
157
+ write_entity_report(run_path, id_path(e), report, client)
158
+ _finished(emit, e.id, report)
159
+ summary = build_summary(
160
+ root.version, reports,
161
+ run_id=run_id, started_at=started, finished_at=_rfc3339(),
162
+ duration_ms=int((time.monotonic() - timer) * 1000),
163
+ config_path=config, report_path=report_path,
164
+ metadata=root.metadata, domain=domain,
165
+ ) # fmt: skip
166
+ write_reports(summary, [], client)
167
+ r, t = summary["run"], summary["results"]
168
+ emit("run_finished", status=r["status"], exit_code=r["exit_code"], files=t["files_total"],
169
+ files_skipped=t["files_skipped"], rows=t["rows_total"], accepted=t["accepted_total"], rejected=t["rejected_total"],
170
+ warnings=t["warnings_total"], errors=t["errors_total"],
171
+ summary_uri=summary["report"]["report_file"], report_base=report_path,
172
+ entity_report_uris={e["name"]: e["report_file"] for e in summary["entities"]}) # fmt: skip
173
+ return RunOutcome(
174
+ run_id, summary, None, report_path, run_path, [r for _, r in reports]
175
+ )