floe-python 0.9.0__py3-none-any.whl → 0.10.1__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.
- floe/__init__.py +14 -4
- floe/_cli.py +19 -5
- floe/add_entity.py +6 -3
- floe/checks/normalize.py +6 -4
- floe/checks/rows.py +9 -3
- floe/config/entity.py +12 -10
- floe/config/include.py +21 -11
- floe/config/profile.py +43 -9
- floe/config/project.py +13 -2
- floe/config/types.py +12 -4
- floe/config/validate.py +65 -24
- floe/config/vars.py +2 -2
- floe/config/yaml.py +6 -39
- floe/errors.py +13 -1
- floe/extras.py +15 -0
- floe/iceberg/catalog.py +40 -3
- floe/iceberg/sink.py +65 -31
- floe/maintain.py +28 -9
- floe/manifest.py +25 -11
- floe/manifest_run.py +30 -8
- floe/read/json.py +10 -3
- floe/read/xlsx.py +7 -2
- floe/read/xml.py +6 -2
- floe/report/summary.py +59 -1
- floe/run/__init__.py +92 -37
- floe/run/entity.py +136 -718
- floe/run/events.py +5 -5
- floe/run/file.py +427 -0
- floe/run/incremental.py +272 -0
- floe/run/parallel.py +18 -8
- floe/run/sources.py +106 -0
- floe/storage/inputs.py +10 -0
- floe/storage/local.py +2 -0
- floe/storage/s3.py +16 -5
- floe/storage/types.py +5 -1
- floe/write/outputs.py +29 -4
- floe_python-0.10.1.dist-info/METADATA +25 -0
- floe_python-0.10.1.dist-info/RECORD +72 -0
- floe_python-0.9.0.dist-info/METADATA +0 -15
- floe_python-0.9.0.dist-info/RECORD +0 -68
- {floe_python-0.9.0.dist-info → floe_python-0.10.1.dist-info}/WHEEL +0 -0
- {floe_python-0.9.0.dist-info → floe_python-0.10.1.dist-info}/entry_points.txt +0 -0
- {floe_python-0.9.0.dist-info → floe_python-0.10.1.dist-info}/licenses/LICENSE +0 -0
floe/__init__.py
CHANGED
|
@@ -15,7 +15,12 @@ __version__ = version(
|
|
|
15
15
|
from floe import manifest as _manifest
|
|
16
16
|
from floe import run as _run
|
|
17
17
|
from floe.config.parse import parse_config
|
|
18
|
-
from floe.config.profile import
|
|
18
|
+
from floe.config.profile import (
|
|
19
|
+
ProfileConfig,
|
|
20
|
+
apply_profile,
|
|
21
|
+
parse_profile,
|
|
22
|
+
validate_runners,
|
|
23
|
+
)
|
|
19
24
|
from floe.config.project import RootConfig as _Root
|
|
20
25
|
from floe.config.validate import validate_config
|
|
21
26
|
from floe.config.vars import extract_config_env_vars as _env_vars
|
|
@@ -32,7 +37,6 @@ from floe.maintain import FLOOR_DAYS, FLOOR_SNAPSHOTS, ORPHAN_DELAY_DAYS
|
|
|
32
37
|
from floe.maintain import maintain as _maintain
|
|
33
38
|
from floe.manifest_run import load_manifest, shown
|
|
34
39
|
from floe.report.summary import id_path
|
|
35
|
-
from floe.run.entity import state_uri
|
|
36
40
|
from floe.run.events import (
|
|
37
41
|
EntityFinishedEvent,
|
|
38
42
|
EntityStartedEvent,
|
|
@@ -44,6 +48,7 @@ from floe.run.events import (
|
|
|
44
48
|
RunStartedEvent,
|
|
45
49
|
SchemaEvolutionAppliedEvent,
|
|
46
50
|
)
|
|
51
|
+
from floe.run.incremental import state_uri
|
|
47
52
|
from floe.state import dump_state, read_state, reset
|
|
48
53
|
from floe.storage.paths import normalize_local_path
|
|
49
54
|
from floe.storage.resolve import clear_storage_clients
|
|
@@ -151,7 +156,9 @@ def validate(
|
|
|
151
156
|
profile_vars: dict[str, str] | None = None,
|
|
152
157
|
profile_path: str | None = None,
|
|
153
158
|
) -> None:
|
|
154
|
-
|
|
159
|
+
profile = _profile(profile_path, profile_vars)
|
|
160
|
+
_, root = _load(config_path, profile)
|
|
161
|
+
validate_runners(root, profile)
|
|
155
162
|
validate_config(root)
|
|
156
163
|
_run.select_entities(root, entities or [])
|
|
157
164
|
|
|
@@ -169,6 +176,7 @@ def run(
|
|
|
169
176
|
if profile: # Rust `run_with_runtime`: profile vars resolved against the config's env vars first
|
|
170
177
|
profile = replace(profile, variables=resolve_vars(profile.variables, {}, _env_vars(Path(config_path).absolute()))) # fmt: skip
|
|
171
178
|
path, root = _load(config_path, profile)
|
|
179
|
+
validate_runners(root, profile)
|
|
172
180
|
# as `floe run -c`: OpenLineage events, and validation after the observer so lineage sees a failure
|
|
173
181
|
out = run_with_lineage(
|
|
174
182
|
path, root, root.entities, str(path), _emit, validate=True, run_id=run_id,
|
|
@@ -190,7 +198,9 @@ def maintain(
|
|
|
190
198
|
experimental: bool = False,
|
|
191
199
|
) -> list[dict[str, Any]]:
|
|
192
200
|
"""`floe maintain`: expire Iceberg snapshots behind the retention floor (7 days, 10 snapshots), then remove orphan files."""
|
|
193
|
-
|
|
201
|
+
profile = _profile(profile_path, profile_vars)
|
|
202
|
+
path, root = _load(config_path, profile)
|
|
203
|
+
validate_runners(root, profile)
|
|
194
204
|
return _maintain(path, root, entities or [], older_than_days, retain_last, orphan_delay_days, dry_run, compact, experimental) # fmt: skip
|
|
195
205
|
|
|
196
206
|
|
floe/_cli.py
CHANGED
|
@@ -15,6 +15,7 @@ from floe.config.profile import (
|
|
|
15
15
|
ProfileConfig,
|
|
16
16
|
apply_profile,
|
|
17
17
|
parse_profile,
|
|
18
|
+
validate_runners,
|
|
18
19
|
)
|
|
19
20
|
from floe.config.project import RootConfig
|
|
20
21
|
from floe.config.validate import validate_config
|
|
@@ -24,13 +25,17 @@ from floe.lineage.observer import run_with_lineage
|
|
|
24
25
|
from floe.maintain import FLOOR_DAYS, FLOOR_SNAPSHOTS, ORPHAN_DELAY_DAYS, maintain
|
|
25
26
|
from floe.manifest_run import load_manifest, shown
|
|
26
27
|
from floe.run import domain_entities, select_entities
|
|
27
|
-
from floe.run.
|
|
28
|
+
from floe.run.incremental import state_uri
|
|
28
29
|
from floe.state import dump_state, read_state, reset
|
|
29
30
|
from floe.storage.paths import normalize_local_path, parse_s3_uri
|
|
30
31
|
from floe.storage.s3 import S3Client
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
def _realpath(path: str) -> Path:
|
|
35
|
+
if path.startswith("s3://"):
|
|
36
|
+
raise FloeConfigError(
|
|
37
|
+
f"{path}: a config must be a local path; download it first"
|
|
38
|
+
)
|
|
34
39
|
try:
|
|
35
40
|
return Path(os.path.realpath(path, strict=True))
|
|
36
41
|
except OSError as e:
|
|
@@ -43,7 +48,9 @@ def _profile(location: str | None) -> ProfileConfig | None:
|
|
|
43
48
|
return parse_profile(location if location.startswith(("s3://", "env:")) else _realpath(location)) # fmt: skip
|
|
44
49
|
|
|
45
50
|
|
|
46
|
-
def _load(
|
|
51
|
+
def _load(
|
|
52
|
+
path: Path, profile: ProfileConfig | None, *, check_runners: bool = True
|
|
53
|
+
) -> RootConfig:
|
|
47
54
|
variables = (
|
|
48
55
|
resolve_vars(profile.variables, {}, extract_config_env_vars(path))
|
|
49
56
|
if profile
|
|
@@ -52,6 +59,8 @@ def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
|
|
|
52
59
|
root = parse_config(path, variables)
|
|
53
60
|
if profile:
|
|
54
61
|
root = apply_profile(root, profile)
|
|
62
|
+
if check_runners:
|
|
63
|
+
validate_runners(root, profile)
|
|
55
64
|
return root
|
|
56
65
|
|
|
57
66
|
|
|
@@ -99,7 +108,7 @@ def _selectors(root: RootConfig, args: argparse.Namespace) -> list[str]:
|
|
|
99
108
|
def _run(args: argparse.Namespace) -> int:
|
|
100
109
|
run_id = args.run_id or datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S.%fZ")
|
|
101
110
|
console = Console(args.log_format, run_id)
|
|
102
|
-
if args.manifest is not None:
|
|
111
|
+
if args.manifest is not None:
|
|
103
112
|
try: # Rust logs a manifest that fails to load after a `run_started`
|
|
104
113
|
root, observed, path = load_manifest(args.manifest)
|
|
105
114
|
except FloeIoError as e: # Rust: a bare io error, logged with code `error`
|
|
@@ -195,7 +204,7 @@ def _state(args: argparse.Namespace) -> int:
|
|
|
195
204
|
raise FloeConfigError("state reset is destructive, rerun with --yes to confirm")
|
|
196
205
|
profile = _profile(args.profile)
|
|
197
206
|
path = _realpath(args.config)
|
|
198
|
-
root = _load(path, profile)
|
|
207
|
+
root = _load(path, profile, check_runners=False)
|
|
199
208
|
[entity] = select_entities(root, [args.entity])
|
|
200
209
|
uri, client = state_uri(entity, normalize_local_path(path.parent), root.storages)
|
|
201
210
|
if args.state_command == "reset":
|
|
@@ -300,7 +309,12 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
300
309
|
|
|
301
310
|
|
|
302
311
|
def main() -> int:
|
|
303
|
-
|
|
312
|
+
parser = _parser()
|
|
313
|
+
args, extra = parser.parse_known_args()
|
|
314
|
+
if args.command == "run" and args.manifest is not None and args.profile is not None:
|
|
315
|
+
parser.error(
|
|
316
|
+
"argument -p/--profile: not allowed with argument --manifest (the manifest is already compiled)"
|
|
317
|
+
)
|
|
304
318
|
if extra: # clap's wording for an unknown flag
|
|
305
319
|
print(f"error: unexpected argument '{extra[0]}' found\n\nFor more information, try '--help'.", file=sys.stderr) # fmt: skip
|
|
306
320
|
return 2
|
floe/add_entity.py
CHANGED
|
@@ -21,7 +21,7 @@ from floe.config.parse import parse_config
|
|
|
21
21
|
from floe.config.project import V0_3, config_version, validate_domains
|
|
22
22
|
from floe.config.types import SourceOptions
|
|
23
23
|
from floe.config.validate import validate_entities
|
|
24
|
-
from floe.config.yaml import _FLOAT, load_yaml, parse_yaml
|
|
24
|
+
from floe.config.yaml import _FLOAT, load_yaml, parse_yaml # pyright: ignore[reportPrivateUsage] -- the YAML float grammar the emitter must quote
|
|
25
25
|
from floe.errors import FloeConfigError, FloeError, FloeIoError
|
|
26
26
|
from floe.glob import MatchOptions, Pattern, PatternError, glob
|
|
27
27
|
|
|
@@ -355,9 +355,9 @@ def _included_domain(
|
|
|
355
355
|
try:
|
|
356
356
|
matches = glob(os.path.join(base, pattern))
|
|
357
357
|
except PatternError as e:
|
|
358
|
-
raise FloeConfigError(f"invalid include.domains pattern {
|
|
358
|
+
raise FloeConfigError(f"invalid include.domains pattern {pattern!r} in {config}: {e}") from e # fmt: skip
|
|
359
359
|
except FloeError as e:
|
|
360
|
-
raise FloeConfigError(f"failed to match include.domains pattern {
|
|
360
|
+
raise FloeConfigError(f"failed to match include.domains pattern {pattern!r} in {config}: {e}") from e # fmt: skip
|
|
361
361
|
for file in matches:
|
|
362
362
|
try:
|
|
363
363
|
docs: list[object] = load_yaml(Path(file))
|
|
@@ -367,6 +367,9 @@ def _included_domain(
|
|
|
367
367
|
raise FloeConfigError(f"failed to parse domain file {file}: {e}") from e
|
|
368
368
|
doc = docs[0] if docs else None
|
|
369
369
|
if isinstance(doc, dict) and cast(Hash, doc).get("name") == domain:
|
|
370
|
+
# ponytail: a sibling would not load; writing into the glob's folder is the upgrade
|
|
371
|
+
if "entities" in doc:
|
|
372
|
+
raise FloeConfigError(f"domain file {file} sets entities; add-entity writes only sibling entity files, so create this one by hand") # fmt: skip
|
|
370
373
|
return os.path.dirname(file), cast(Hash, doc).get("defaults", {})
|
|
371
374
|
return None
|
|
372
375
|
|
floe/checks/normalize.py
CHANGED
|
@@ -72,13 +72,15 @@ def source_names(columns: Sequence[ColumnConfig], strategy: str | None) -> list[
|
|
|
72
72
|
"""Normalised `source or name` of each column; two columns on one name are an error."""
|
|
73
73
|
seen: dict[str, str] = {}
|
|
74
74
|
for column in columns:
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
name = normalize_name(column.source or column.name, strategy)
|
|
76
|
+
label = (
|
|
77
|
+
f"{column.name} (source {column.source})" if column.source else column.name
|
|
78
|
+
)
|
|
77
79
|
if name in seen:
|
|
78
80
|
raise FloeConfigError(
|
|
79
|
-
f"column source collision: {seen[name]} and {
|
|
81
|
+
f"column source collision: {seen[name]} and {label} -> {name}"
|
|
80
82
|
)
|
|
81
|
-
seen[name] =
|
|
83
|
+
seen[name] = label
|
|
82
84
|
return list(seen)
|
|
83
85
|
|
|
84
86
|
|
floe/checks/rows.py
CHANGED
|
@@ -107,14 +107,20 @@ def split_rows(
|
|
|
107
107
|
checks: Sequence[RowCheck],
|
|
108
108
|
formatter: Formatter = "json",
|
|
109
109
|
sources: Mapping[str, str] | None = None,
|
|
110
|
+
raw: pl.DataFrame | None = None,
|
|
110
111
|
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
111
|
-
"""`(accepted, rejected)`; rejected adds `__floe_row_index` (UInt64, row position in `df`) and `__floe_errors`.
|
|
112
|
+
"""`(accepted, rejected)`; rejected adds `__floe_row_index` (UInt64, row position in `df`) and `__floe_errors`.
|
|
113
|
+
|
|
114
|
+
`raw` is the row-aligned frame before the casts: the rejected rows are taken from it, so a failed cast keeps its source value.
|
|
115
|
+
"""
|
|
116
|
+
src = df if raw is None else raw
|
|
112
117
|
# a null mask passes the row, as in `error_column`
|
|
113
118
|
if not any(c.mask.any() for c in checks):
|
|
114
119
|
none = [pl.Series(ROW_INDEX, [], pl.UInt64), pl.Series(ERRORS, [], pl.String)]
|
|
115
|
-
|
|
120
|
+
# with_columns replaces a clashing column, as below
|
|
121
|
+
return df, src.clear().with_columns(none)
|
|
116
122
|
errors = error_column(df.height, checks, formatter, sources)
|
|
117
|
-
rejected =
|
|
123
|
+
rejected = src.with_columns(
|
|
118
124
|
pl.int_range(pl.len(), dtype=pl.UInt64).alias(ROW_INDEX), errors
|
|
119
125
|
).filter(errors.is_not_null())
|
|
120
126
|
return df.filter(errors.is_null()), rejected
|
floe/config/entity.py
CHANGED
|
@@ -44,12 +44,11 @@ from floe.config.yaml import (
|
|
|
44
44
|
opt_u64,
|
|
45
45
|
opt_vec_string,
|
|
46
46
|
opt_vec_vec_string,
|
|
47
|
-
rust_debug,
|
|
48
47
|
validate_known_keys,
|
|
49
48
|
yaml_array,
|
|
50
49
|
yaml_hash,
|
|
51
50
|
)
|
|
52
|
-
from floe.errors import FloeConfigError, FloeError
|
|
51
|
+
from floe.errors import NOT_SUPPORTED, FloeConfigError, FloeError
|
|
53
52
|
from floe.pii import PiiColumn, Strategy
|
|
54
53
|
|
|
55
54
|
_T = TypeVar("_T", bound=str)
|
|
@@ -164,17 +163,16 @@ def _schema_evolution(value: object) -> SchemaEvolutionConfig:
|
|
|
164
163
|
def _column(value: object) -> ColumnConfig:
|
|
165
164
|
ctx = "schema.columns"
|
|
166
165
|
h = yaml_hash(value, ctx)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
for key in ("width", "trim"):
|
|
167
|
+
if key in h:
|
|
168
|
+
raise FloeConfigError(f"{ctx}.{key} {NOT_SUPPORTED}")
|
|
169
|
+
validate_known_keys(h, ctx, ("name", "source", "type", "nullable", "unique"))
|
|
170
170
|
return ColumnConfig(
|
|
171
171
|
name=get_string(h, "name", ctx),
|
|
172
172
|
source=opt_string(h, "source", ctx),
|
|
173
173
|
column_type=get_string(h, "type", ctx),
|
|
174
174
|
nullable=opt_bool(h, "nullable", ctx),
|
|
175
175
|
unique=opt_bool(h, "unique", ctx),
|
|
176
|
-
width=opt_u64(h, "width", ctx),
|
|
177
|
-
trim=opt_bool(h, "trim", ctx),
|
|
178
176
|
)
|
|
179
177
|
|
|
180
178
|
|
|
@@ -367,9 +365,9 @@ def _sink_target(
|
|
|
367
365
|
value: object, ctx: str, allow_options: bool, write_mode: WriteMode
|
|
368
366
|
) -> SinkTarget:
|
|
369
367
|
h = yaml_hash(value, ctx)
|
|
370
|
-
allowed = ("format", "path", "storage", "filesystem")
|
|
368
|
+
allowed = ("format", "path", "storage", "filesystem", "iceberg")
|
|
371
369
|
if allow_options:
|
|
372
|
-
allowed += ("options", "merge", "
|
|
370
|
+
allowed += ("options", "merge", "delta", "duckdb")
|
|
373
371
|
allowed += ("partition_by", "partition_spec")
|
|
374
372
|
validate_known_keys(h, ctx, allowed)
|
|
375
373
|
storage = _storage(h, ctx)
|
|
@@ -380,7 +378,7 @@ def _sink_target(
|
|
|
380
378
|
fmt = get_string(h, "format", ctx)
|
|
381
379
|
if "duckdb" in h and fmt != "duckdb":
|
|
382
380
|
raise FloeConfigError(
|
|
383
|
-
f'{ctx}.duckdb is only valid when format is "duckdb" (found format={
|
|
381
|
+
f'{ctx}.duckdb is only valid when format is "duckdb" (found format={fmt!r})'
|
|
384
382
|
)
|
|
385
383
|
spec = _partition_spec(h, ctx)
|
|
386
384
|
return SinkTarget(
|
|
@@ -440,6 +438,7 @@ def parse_entity(
|
|
|
440
438
|
"name",
|
|
441
439
|
"metadata",
|
|
442
440
|
"domain",
|
|
441
|
+
"runner",
|
|
443
442
|
"incremental_mode",
|
|
444
443
|
"state",
|
|
445
444
|
"source",
|
|
@@ -453,6 +452,8 @@ def parse_entity(
|
|
|
453
452
|
metadata = _metadata(h["metadata"]) if "metadata" in h else None
|
|
454
453
|
domain = opt_string(h, "domain", "entity")
|
|
455
454
|
mode = opt_string(h, "incremental_mode", "entity")
|
|
455
|
+
if mode is not None and _norm(mode) == "row":
|
|
456
|
+
raise FloeConfigError(f"incremental_mode=row {NOT_SUPPORTED}")
|
|
456
457
|
incremental_mode: IncrementalMode = (
|
|
457
458
|
"none"
|
|
458
459
|
if mode is None
|
|
@@ -469,6 +470,7 @@ def parse_entity(
|
|
|
469
470
|
id=name,
|
|
470
471
|
metadata=metadata,
|
|
471
472
|
domain=domain,
|
|
473
|
+
runner=opt_string(h, "runner", "entity"),
|
|
472
474
|
incremental_mode=incremental_mode,
|
|
473
475
|
state=state,
|
|
474
476
|
source=source,
|
floe/config/include.py
CHANGED
|
@@ -11,7 +11,6 @@ from floe.config.yaml import (
|
|
|
11
11
|
get_value,
|
|
12
12
|
load_single_doc,
|
|
13
13
|
opt_string,
|
|
14
|
-
rust_debug,
|
|
15
14
|
validate_known_keys,
|
|
16
15
|
yaml_array,
|
|
17
16
|
yaml_hash,
|
|
@@ -31,6 +30,21 @@ def glob_base(directory: Path) -> str:
|
|
|
31
30
|
return Pattern.escape(text)
|
|
32
31
|
|
|
33
32
|
|
|
33
|
+
def _entity_files(domain: Hash, domain_file: Path) -> list[Path]:
|
|
34
|
+
"""Its `entities` glob (#1213) if the domain file sets one, else its sibling `*.yml` files."""
|
|
35
|
+
directory = domain_file.parent
|
|
36
|
+
if "entities" not in domain:
|
|
37
|
+
files = [p for p in directory.iterdir() if p.suffix == ".yml"]
|
|
38
|
+
else:
|
|
39
|
+
pattern = yaml_string(domain.pop("entities"), "domains.entities")
|
|
40
|
+
files = [Path(m) for m in glob(os.path.join(glob_base(directory), pattern))]
|
|
41
|
+
if not files:
|
|
42
|
+
raise FloeConfigError(
|
|
43
|
+
f"domains.entities pattern {pattern!r} matched no file"
|
|
44
|
+
)
|
|
45
|
+
return sorted(p for p in files if p.resolve() != domain_file.resolve())
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
def assemble_includes(
|
|
35
49
|
doc: object, config_dir: Path, config_files: list[Path]
|
|
36
50
|
) -> IncludeOrigins:
|
|
@@ -75,23 +89,19 @@ def assemble_includes(
|
|
|
75
89
|
matches = glob(os.path.join(glob_base(config_dir), pattern))
|
|
76
90
|
if not matches:
|
|
77
91
|
raise FloeConfigError(
|
|
78
|
-
f"include.domains pattern {
|
|
92
|
+
f"include.domains pattern {pattern!r} matched no file "
|
|
79
93
|
"(includes resolve against local files only; a remote project cannot include)"
|
|
80
94
|
)
|
|
81
95
|
for match in matches:
|
|
82
96
|
domain_file = Path(match)
|
|
83
97
|
with at_file(domain_file):
|
|
84
|
-
domain = load_single_doc(domain_file)
|
|
85
|
-
name = get_string(
|
|
98
|
+
domain = yaml_hash(load_single_doc(domain_file), "domains")
|
|
99
|
+
name = get_string(domain, "name", "domains")
|
|
100
|
+
entity_files = _entity_files(domain, domain_file)
|
|
86
101
|
config_files.append(domain_file)
|
|
87
102
|
domains.append((domain, domain_file))
|
|
88
103
|
# D4: an entity's name comes from its YAML, never from its file name, so `con.yml` loads like any other
|
|
89
|
-
|
|
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):
|
|
104
|
+
for entity_file in entity_files:
|
|
95
105
|
with at_file(entity_file):
|
|
96
106
|
loaded = load_single_doc(entity_file)
|
|
97
107
|
if not isinstance(loaded, dict):
|
|
@@ -103,7 +113,7 @@ def assemble_includes(
|
|
|
103
113
|
entity["domain"] = name
|
|
104
114
|
elif declared != name:
|
|
105
115
|
raise FloeConfigError(
|
|
106
|
-
f"{entity_file} has domain {
|
|
116
|
+
f"{entity_file} has domain {declared!r} but its _domain.yml names {name!r}"
|
|
107
117
|
)
|
|
108
118
|
config_files.append(entity_file)
|
|
109
119
|
entities.append((entity, entity_file))
|
floe/config/profile.py
CHANGED
|
@@ -18,14 +18,14 @@ from floe.config.validate import check_lineage
|
|
|
18
18
|
from floe.config.vars import string_map
|
|
19
19
|
from floe.config.yaml import (
|
|
20
20
|
load_yaml,
|
|
21
|
+
merge_yaml,
|
|
21
22
|
parse_yaml,
|
|
22
|
-
rust_debug,
|
|
23
23
|
validate_known_keys,
|
|
24
24
|
yaml_array,
|
|
25
25
|
yaml_hash,
|
|
26
26
|
yaml_string,
|
|
27
27
|
)
|
|
28
|
-
from floe.errors import FloeConfigError, FloeIoError
|
|
28
|
+
from floe.errors import NOT_SUPPORTED, FloeConfigError, FloeIoError
|
|
29
29
|
from floe.storage.s3 import read_s3
|
|
30
30
|
|
|
31
31
|
Hash = dict[object, object]
|
|
@@ -53,6 +53,7 @@ class ProfileConfig:
|
|
|
53
53
|
storages: StoragesConfig | None
|
|
54
54
|
lineage: LineageConfig | None
|
|
55
55
|
orchestration: dict[str, object] | None = None # its set fields only, as Rust
|
|
56
|
+
runners: dict[str, dict[str, object]] | None = None
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
def _string(hash: Hash, key: str, ctx: str, required: bool = False) -> str | None:
|
|
@@ -260,9 +261,9 @@ def _metadata(value: object) -> str:
|
|
|
260
261
|
def _validation(value: object) -> None:
|
|
261
262
|
ctx = "profile.validation"
|
|
262
263
|
hash = yaml_hash(value, ctx)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
if "strict" in hash:
|
|
265
|
+
raise FloeConfigError(f"{ctx}.strict {NOT_SUPPORTED}")
|
|
266
|
+
validate_known_keys(hash, ctx, ())
|
|
266
267
|
|
|
267
268
|
|
|
268
269
|
def _parse_doc(doc: object) -> ProfileConfig:
|
|
@@ -284,13 +285,23 @@ def _parse_doc(doc: object) -> ProfileConfig:
|
|
|
284
285
|
name = _metadata(root["metadata"])
|
|
285
286
|
runner: dict[str, object] = {}
|
|
286
287
|
orchestration = None
|
|
288
|
+
runners: dict[str, dict[str, object]] = {}
|
|
287
289
|
if "execution" in root:
|
|
288
290
|
ctx = "profile.execution"
|
|
289
291
|
execution = yaml_hash(root["execution"], ctx)
|
|
290
|
-
validate_known_keys(execution, ctx, ("runner", "orchestration"))
|
|
292
|
+
validate_known_keys(execution, ctx, ("runner", "runners", "orchestration"))
|
|
291
293
|
if "runner" not in execution:
|
|
292
294
|
raise FloeConfigError(f"{ctx}.runner is required")
|
|
293
295
|
runner = _runner(execution["runner"])
|
|
296
|
+
if "runners" in execution:
|
|
297
|
+
named = yaml_hash(execution["runners"], f"{ctx}.runners")
|
|
298
|
+
for key, value in named.items():
|
|
299
|
+
runner_name = yaml_string(key, f"{ctx}.runners name")
|
|
300
|
+
if not runner_name.strip() or runner_name in ("default", "local"):
|
|
301
|
+
raise FloeConfigError(
|
|
302
|
+
f"{ctx}.runners: invalid runner name {runner_name!r}"
|
|
303
|
+
)
|
|
304
|
+
runners[runner_name] = _runner(merge_yaml(execution["runner"], value))
|
|
294
305
|
if "orchestration" in execution:
|
|
295
306
|
orchestration = _orchestration(execution["orchestration"])
|
|
296
307
|
variables = (
|
|
@@ -308,7 +319,14 @@ def _parse_doc(doc: object) -> ProfileConfig:
|
|
|
308
319
|
if "validation" in root:
|
|
309
320
|
_validation(root["validation"])
|
|
310
321
|
return ProfileConfig(
|
|
311
|
-
name,
|
|
322
|
+
name,
|
|
323
|
+
runner,
|
|
324
|
+
variables,
|
|
325
|
+
catalogs,
|
|
326
|
+
storages,
|
|
327
|
+
lineage,
|
|
328
|
+
orchestration=orchestration,
|
|
329
|
+
runners=runners,
|
|
312
330
|
)
|
|
313
331
|
|
|
314
332
|
|
|
@@ -319,7 +337,7 @@ def _malformed(value: str) -> str | None:
|
|
|
319
337
|
rest = rest[start + 2 :]
|
|
320
338
|
end = rest.find("}")
|
|
321
339
|
if end == -1:
|
|
322
|
-
return f"${{... (unclosed placeholder in: {
|
|
340
|
+
return f"${{... (unclosed placeholder in: {value!r})}}"[:120]
|
|
323
341
|
if not rest[:end].strip():
|
|
324
342
|
return "${} (empty placeholder)"
|
|
325
343
|
rest = rest[end + 1 :]
|
|
@@ -390,6 +408,8 @@ def validate_profile(profile: ProfileConfig) -> None:
|
|
|
390
408
|
value = cast(dict[str, object], value).get(sub)
|
|
391
409
|
if not isinstance(value, str) or not value.strip():
|
|
392
410
|
raise FloeConfigError(f"{ctx}.{field} is required for {kind}")
|
|
411
|
+
for name, runner in (profile.runners or {}).items():
|
|
412
|
+
validate_profile(ProfileConfig(name, runner, {}, None, None, None))
|
|
393
413
|
for key, value in profile.variables.items():
|
|
394
414
|
if (placeholder := _malformed(value)) is not None:
|
|
395
415
|
raise FloeConfigError(
|
|
@@ -411,8 +431,22 @@ def entity_workers(orchestration: Mapping[str, object] | None) -> int:
|
|
|
411
431
|
return limit if isinstance(limit, int) else min(4, os.cpu_count() or 1)
|
|
412
432
|
|
|
413
433
|
|
|
434
|
+
def validate_runners(config: RootConfig, profile: ProfileConfig | None) -> None:
|
|
435
|
+
default = (
|
|
436
|
+
"default"
|
|
437
|
+
if profile and profile.runner.get("type") not in (None, "local")
|
|
438
|
+
else "local"
|
|
439
|
+
)
|
|
440
|
+
declared = {default, *((profile.runners or {}) if profile else {})}
|
|
441
|
+
for entity in config.entities:
|
|
442
|
+
if entity.runner is not None and entity.runner not in declared:
|
|
443
|
+
available = ", ".join(sorted(declared))
|
|
444
|
+
raise FloeConfigError(
|
|
445
|
+
f"entity {entity.id} runner {entity.runner!r} is not declared; available: {available}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
414
449
|
def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
|
|
415
|
-
"""The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
|
|
416
450
|
blocks = {
|
|
417
451
|
"catalogs": profile.catalogs,
|
|
418
452
|
"storages": profile.storages,
|
floe/config/project.py
CHANGED
|
@@ -78,6 +78,7 @@ class LineageConfig(BaseModel):
|
|
|
78
78
|
class DomainConfig(BaseModel):
|
|
79
79
|
name: str
|
|
80
80
|
incoming_dir: str | None
|
|
81
|
+
runner: str | None = None
|
|
81
82
|
defaults: Hash | None # raw; `validate_domains` rejects the keys D7 forbids
|
|
82
83
|
resolved_incoming_dir: str | None = None # set by the template pass
|
|
83
84
|
|
|
@@ -150,7 +151,7 @@ def _hint(entity: object, key: str) -> str | None:
|
|
|
150
151
|
|
|
151
152
|
def parse_domain(item: object, version: str) -> DomainConfig:
|
|
152
153
|
hash = yaml_hash(item, "domains")
|
|
153
|
-
validate_known_keys(hash, "domains", ("name", "incoming_dir", "defaults"))
|
|
154
|
+
validate_known_keys(hash, "domains", ("name", "incoming_dir", "defaults", "runner"))
|
|
154
155
|
name = get_string(hash, "name", "domains")
|
|
155
156
|
defaults = None
|
|
156
157
|
if "defaults" in hash:
|
|
@@ -160,7 +161,12 @@ def parse_domain(item: object, version: str) -> DomainConfig:
|
|
|
160
161
|
incoming_dir = opt_string(hash, "incoming_dir", "domains")
|
|
161
162
|
if incoming_dir is None and config_version(version) < V0_3:
|
|
162
163
|
raise FloeConfigError("missing required field domains.incoming_dir")
|
|
163
|
-
return DomainConfig(
|
|
164
|
+
return DomainConfig(
|
|
165
|
+
name=name,
|
|
166
|
+
incoming_dir=incoming_dir,
|
|
167
|
+
defaults=defaults,
|
|
168
|
+
runner=opt_string(hash, "runner", "domains"),
|
|
169
|
+
)
|
|
164
170
|
|
|
165
171
|
|
|
166
172
|
def parse_sources(value: object, version: str, origin: Path | None) -> dict[str, Hash]:
|
|
@@ -241,6 +247,11 @@ def parse_entities(
|
|
|
241
247
|
for index, entity in enumerate(entities):
|
|
242
248
|
try:
|
|
243
249
|
parsed = parse_entity(*_merge_entity(entity, sources, defaults))
|
|
250
|
+
if parsed.runner is None:
|
|
251
|
+
runner = next(
|
|
252
|
+
(d.runner for d in domains if d.name == parsed.domain), None
|
|
253
|
+
)
|
|
254
|
+
parsed = parsed.model_copy(update={"runner": runner})
|
|
244
255
|
if qualified and parsed.domain is not None:
|
|
245
256
|
parsed = parsed.model_copy(
|
|
246
257
|
update={"id": f"{parsed.domain}.{parsed.name}"}
|
floe/config/types.py
CHANGED
|
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
6
6
|
|
|
7
7
|
from floe.pii import PiiColumn
|
|
8
8
|
|
|
9
|
-
IncrementalMode = Literal["none", "archive", "file"
|
|
9
|
+
IncrementalMode = Literal["none", "archive", "file"]
|
|
10
10
|
PolicySeverity = Literal["warn", "reject", "abort"]
|
|
11
11
|
SchemaEvolutionMode = Literal["strict", "add_columns"]
|
|
12
12
|
SchemaEvolutionIncompatibleAction = Literal["fail"]
|
|
@@ -54,8 +54,6 @@ class ColumnConfig(_Model):
|
|
|
54
54
|
column_type: str
|
|
55
55
|
nullable: bool | None
|
|
56
56
|
unique: bool | None
|
|
57
|
-
width: int | None
|
|
58
|
-
trim: bool | None
|
|
59
57
|
|
|
60
58
|
|
|
61
59
|
class SchemaConfig(_Model):
|
|
@@ -126,6 +124,15 @@ class IcebergSinkOptions(_Model):
|
|
|
126
124
|
location: str | None
|
|
127
125
|
|
|
128
126
|
|
|
127
|
+
def catalog_ident(value: str) -> str:
|
|
128
|
+
"""Rust `normalize_catalog_ident`."""
|
|
129
|
+
out = "".join(c.lower() if c.isascii() and (c.isalnum() or c in "_-") else "_" for c in value) # fmt: skip
|
|
130
|
+
return out.strip("_") or "default"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
NO_ICEBERG = IcebergSinkOptions(catalog=None, namespace=None, table=None, location=None) # fmt: skip
|
|
134
|
+
|
|
135
|
+
|
|
129
136
|
class IcebergPartitionField(_Model):
|
|
130
137
|
column: str
|
|
131
138
|
transform: str
|
|
@@ -167,6 +174,7 @@ class EntityConfig(_Model):
|
|
|
167
174
|
id: str
|
|
168
175
|
metadata: EntityMetadata | None
|
|
169
176
|
domain: str | None
|
|
177
|
+
runner: str | None = None
|
|
170
178
|
incremental_mode: IncrementalMode
|
|
171
179
|
state: EntityStateConfig | None
|
|
172
180
|
source: SourceConfig
|
|
@@ -185,7 +193,7 @@ def with_partition_columns(entity: EntityConfig) -> EntityConfig:
|
|
|
185
193
|
date_type = "datetime" if "{HH}" in p.pattern else "date"
|
|
186
194
|
derived = [(p.date_column, date_type), (p.batch_column, "string")]
|
|
187
195
|
columns = [
|
|
188
|
-
ColumnConfig(name=n, source=n, column_type=t, nullable=False, unique=None
|
|
196
|
+
ColumnConfig(name=n, source=n, column_type=t, nullable=False, unique=None)
|
|
189
197
|
for n, t in derived
|
|
190
198
|
if n is not None
|
|
191
199
|
] # fmt: skip # `source` keeps the name out of normalize_columns
|