floe-python 0.8.9__py3-none-any.whl → 0.10.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 (47) hide show
  1. floe/__init__.py +14 -4
  2. floe/_cli.py +19 -5
  3. floe/add_entity.py +3 -3
  4. floe/checks/normalize.py +6 -4
  5. floe/checks/rows.py +9 -3
  6. floe/config/entity.py +12 -10
  7. floe/config/include.py +2 -3
  8. floe/config/profile.py +72 -30
  9. floe/config/project.py +13 -2
  10. floe/config/types.py +12 -4
  11. floe/config/validate.py +65 -24
  12. floe/config/vars.py +2 -2
  13. floe/config/yaml.py +6 -39
  14. floe/errors.py +13 -1
  15. floe/extras.py +15 -0
  16. floe/iceberg/catalog.py +40 -3
  17. floe/iceberg/compact.py +3 -1
  18. floe/iceberg/sink.py +65 -31
  19. floe/maintain.py +28 -9
  20. floe/manifest.py +28 -17
  21. floe/manifest_run.py +30 -8
  22. floe/read/json.py +10 -3
  23. floe/read/xlsx.py +7 -2
  24. floe/read/xml.py +6 -2
  25. floe/report/summary.py +59 -1
  26. floe/run/__init__.py +92 -37
  27. floe/run/entity.py +138 -698
  28. floe/run/events.py +5 -5
  29. floe/run/file.py +427 -0
  30. floe/run/incremental.py +272 -0
  31. floe/run/parallel.py +18 -8
  32. floe/run/sources.py +106 -0
  33. floe/state.py +34 -8
  34. floe/storage/archive.py +1 -1
  35. floe/storage/inputs.py +10 -0
  36. floe/storage/local.py +7 -1
  37. floe/storage/partitioning.py +16 -6
  38. floe/storage/s3.py +47 -6
  39. floe/storage/types.py +9 -2
  40. floe/write/outputs.py +29 -4
  41. floe_python-0.10.0.dist-info/METADATA +25 -0
  42. floe_python-0.10.0.dist-info/RECORD +72 -0
  43. floe_python-0.8.9.dist-info/METADATA +0 -15
  44. floe_python-0.8.9.dist-info/RECORD +0 -68
  45. {floe_python-0.8.9.dist-info → floe_python-0.10.0.dist-info}/WHEEL +0 -0
  46. {floe_python-0.8.9.dist-info → floe_python-0.10.0.dist-info}/entry_points.txt +0 -0
  47. {floe_python-0.8.9.dist-info → floe_python-0.10.0.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 ProfileConfig, apply_profile, parse_profile
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
- _, root = _load(config_path, _profile(profile_path, profile_vars))
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
- path, root = _load(config_path, _profile(profile_path, profile_vars))
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.entity import state_uri
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(path: Path, profile: ProfileConfig | None) -> RootConfig:
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: # Rust ignores `-p` here: the manifest is compiled
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
- args, extra = _parser().parse_known_args()
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, rust_debug # pyright: ignore[reportPrivateUsage] -- the YAML float grammar the emitter must quote
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 {rust_debug(pattern)} in {config}: {e}") from e # fmt: skip
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 {rust_debug(pattern)} in {config}: {e}") from e # fmt: skip
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))
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
- source = column.source or column.name
76
- name = normalize_name(source, strategy)
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 {source} -> {name}"
81
+ f"column source collision: {seen[name]} and {label} -> {name}"
80
82
  )
81
- seen[name] = source
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
- return df, df.clear().with_columns(none) # replaces a clashing column, as below
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 = df.with_columns(
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
- validate_known_keys(
168
- h, ctx, ("name", "source", "type", "nullable", "unique", "width", "trim")
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", "iceberg", "delta", "duckdb")
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={rust_debug(fmt)})'
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,
@@ -75,7 +74,7 @@ def assemble_includes(
75
74
  matches = glob(os.path.join(glob_base(config_dir), pattern))
76
75
  if not matches:
77
76
  raise FloeConfigError(
78
- f"include.domains pattern {rust_debug(pattern)} matched no file "
77
+ f"include.domains pattern {pattern!r} matched no file "
79
78
  "(includes resolve against local files only; a remote project cannot include)"
80
79
  )
81
80
  for match in matches:
@@ -103,7 +102,7 @@ def assemble_includes(
103
102
  entity["domain"] = name
104
103
  elif declared != name:
105
104
  raise FloeConfigError(
106
- f"{entity_file} has domain {rust_debug(declared)} but its _domain.yml names {rust_debug(name)}"
105
+ f"{entity_file} has domain {declared!r} but its _domain.yml names {name!r}"
107
106
  )
108
107
  config_files.append(entity_file)
109
108
  entities.append((entity, entity_file))
floe/config/profile.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
2
2
 
3
+ import math
3
4
  import os
4
5
  from collections.abc import Callable, Mapping
5
6
  from dataclasses import dataclass
@@ -17,14 +18,14 @@ from floe.config.validate import check_lineage
17
18
  from floe.config.vars import string_map
18
19
  from floe.config.yaml import (
19
20
  load_yaml,
21
+ merge_yaml,
20
22
  parse_yaml,
21
- rust_debug,
22
23
  validate_known_keys,
23
24
  yaml_array,
24
25
  yaml_hash,
25
26
  yaml_string,
26
27
  )
27
- from floe.errors import FloeConfigError, FloeIoError
28
+ from floe.errors import NOT_SUPPORTED, FloeConfigError, FloeIoError
28
29
  from floe.storage.s3 import read_s3
29
30
 
30
31
  Hash = dict[object, object]
@@ -45,12 +46,14 @@ CONTRACT = {
45
46
  @dataclass(frozen=True)
46
47
  class ProfileConfig:
47
48
  name: str
48
- runner: dict[str, object] # `type` and every field, None when unset
49
+ # `type` and every field, None when unset; then a kubernetes_job's pass-through keys
50
+ runner: dict[str, object]
49
51
  variables: dict[str, str]
50
52
  catalogs: CatalogsConfig | None
51
53
  storages: StoragesConfig | None
52
54
  lineage: LineageConfig | None
53
55
  orchestration: dict[str, object] | None = None # its set fields only, as Rust
56
+ runners: dict[str, dict[str, object]] | None = None
54
57
 
55
58
 
56
59
  def _string(hash: Hash, key: str, ctx: str, required: bool = False) -> str | None:
@@ -157,17 +160,6 @@ def _str_keyed(value: object, where: str) -> dict[str, object]:
157
160
  return cast(dict[str, object], hash)
158
161
 
159
162
 
160
- def _object(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
161
- return _str_keyed(hash[key], f"{ctx}.{key}") if key in hash else None
162
-
163
-
164
- def _objects(hash: Hash, key: str, ctx: str) -> list[dict[str, object]] | None:
165
- if key not in hash:
166
- return None
167
- where = f"{ctx}.{key}"
168
- return [_str_keyed(item, f"{where}[]") for item in yaml_array(hash[key], where)]
169
-
170
-
171
163
  def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
172
164
  if key not in hash:
173
165
  return None
@@ -200,24 +192,41 @@ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
200
192
  ("env_parameters", _map),
201
193
  ("job_scope", _job_scope),
202
194
  ("domain_resources", _domain_resources),
203
- ("labels", _map),
204
- ("annotations", _map),
205
- ("image_pull_policy", _string),
206
- ("security_context", _object),
207
- ("node_selector", _map),
208
- ("tolerations", _objects),
209
- ("backoff_limit", _u64),
210
- ("env_from", _objects),
195
+ )
196
+ # Legacy: the keys any runner type accepted before #1065. A non-k8s runner still accepts and ignores them,
197
+ # so a profile copied from a kubernetes_job keeps loading; they are not validated here.
198
+ _LEGACY_IGNORED = (
199
+ *("labels", "annotations", "image_pull_policy", "security_context"),
200
+ *("node_selector", "tolerations", "backoff_limit", "env_from"),
211
201
  )
212
202
 
213
203
 
204
+ def _json(value: object, where: str) -> object:
205
+ """A pass-through value as written, once JSON can carry it: string mapping keys, finite numbers."""
206
+ if isinstance(value, dict):
207
+ hash = _str_keyed(cast(object, value), where)
208
+ return {k: _json(v, f"{where}.{k}") for k, v in hash.items()}
209
+ if isinstance(value, list):
210
+ return [_json(v, f"{where}[]") for v in cast(list[object], value)]
211
+ if isinstance(value, float) and not math.isfinite(value):
212
+ raise FloeConfigError(f"{where} must be a finite number")
213
+ return value
214
+
215
+
214
216
  def _runner(value: object) -> dict[str, object]:
215
217
  ctx = "profile.execution.runner"
216
218
  hash = yaml_hash(value, ctx)
217
- validate_known_keys(hash, ctx, ("type", *(name for name, _ in _RUNNER_FIELDS)))
219
+ known = ("type", *(name for name, _ in _RUNNER_FIELDS))
220
+ # #1065: a kubernetes_job's other keys are pod options its orchestrator validates; they pass through
221
+ open_ = hash.get("type") == "kubernetes_job"
222
+ validate_known_keys(hash, ctx, hash.keys() if open_ else (*known, *_LEGACY_IGNORED))
218
223
  read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
219
224
  for name, reader in _RUNNER_FIELDS:
220
225
  read[name] = reader(hash, name, ctx)
226
+ if open_:
227
+ read |= {
228
+ str(k): _json(v, f"{ctx}.{k}") for k, v in hash.items() if k not in known
229
+ }
221
230
  return read
222
231
 
223
232
 
@@ -252,9 +261,9 @@ def _metadata(value: object) -> str:
252
261
  def _validation(value: object) -> None:
253
262
  ctx = "profile.validation"
254
263
  hash = yaml_hash(value, ctx)
255
- validate_known_keys(hash, ctx, ("strict",))
256
- if "strict" in hash and not isinstance(hash["strict"], bool):
257
- raise FloeConfigError(f"{ctx}.strict must be a boolean")
264
+ if "strict" in hash:
265
+ raise FloeConfigError(f"{ctx}.strict {NOT_SUPPORTED}")
266
+ validate_known_keys(hash, ctx, ())
258
267
 
259
268
 
260
269
  def _parse_doc(doc: object) -> ProfileConfig:
@@ -276,13 +285,23 @@ def _parse_doc(doc: object) -> ProfileConfig:
276
285
  name = _metadata(root["metadata"])
277
286
  runner: dict[str, object] = {}
278
287
  orchestration = None
288
+ runners: dict[str, dict[str, object]] = {}
279
289
  if "execution" in root:
280
290
  ctx = "profile.execution"
281
291
  execution = yaml_hash(root["execution"], ctx)
282
- validate_known_keys(execution, ctx, ("runner", "orchestration"))
292
+ validate_known_keys(execution, ctx, ("runner", "runners", "orchestration"))
283
293
  if "runner" not in execution:
284
294
  raise FloeConfigError(f"{ctx}.runner is required")
285
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))
286
305
  if "orchestration" in execution:
287
306
  orchestration = _orchestration(execution["orchestration"])
288
307
  variables = (
@@ -300,7 +319,14 @@ def _parse_doc(doc: object) -> ProfileConfig:
300
319
  if "validation" in root:
301
320
  _validation(root["validation"])
302
321
  return ProfileConfig(
303
- name, runner, variables, catalogs, storages, lineage, orchestration
322
+ name,
323
+ runner,
324
+ variables,
325
+ catalogs,
326
+ storages,
327
+ lineage,
328
+ orchestration=orchestration,
329
+ runners=runners,
304
330
  )
305
331
 
306
332
 
@@ -311,7 +337,7 @@ def _malformed(value: str) -> str | None:
311
337
  rest = rest[start + 2 :]
312
338
  end = rest.find("}")
313
339
  if end == -1:
314
- return f"${{... (unclosed placeholder in: {rust_debug(value)})}}"[:120]
340
+ return f"${{... (unclosed placeholder in: {value!r})}}"[:120]
315
341
  if not rest[:end].strip():
316
342
  return "${} (empty placeholder)"
317
343
  rest = rest[end + 1 :]
@@ -382,6 +408,8 @@ def validate_profile(profile: ProfileConfig) -> None:
382
408
  value = cast(dict[str, object], value).get(sub)
383
409
  if not isinstance(value, str) or not value.strip():
384
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))
385
413
  for key, value in profile.variables.items():
386
414
  if (placeholder := _malformed(value)) is not None:
387
415
  raise FloeConfigError(
@@ -403,8 +431,22 @@ def entity_workers(orchestration: Mapping[str, object] | None) -> int:
403
431
  return limit if isinstance(limit, int) else min(4, os.cpu_count() or 1)
404
432
 
405
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
+
406
449
  def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
407
- """The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
408
450
  blocks = {
409
451
  "catalogs": profile.catalogs,
410
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(name=name, incoming_dir=incoming_dir, defaults=defaults)
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", "row"]
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, width=None, trim=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