floe-python 0.8.0__py3-none-any.whl → 0.8.2__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 CHANGED
@@ -132,7 +132,7 @@ def _profile(
132
132
  if path is None:
133
133
  return None if variables is None else ProfileConfig("", {}, variables, None, None, None) # fmt: skip
134
134
  try:
135
- profile = parse_profile(Path(path))
135
+ profile = parse_profile(path)
136
136
  except FloeError as e:
137
137
  raise FloeConfigError(f"failed to load profile {_debug(path)}: {e}") from e
138
138
  return ProfileConfig(profile.name, profile.runner, profile.variables | (variables or {}),
@@ -225,10 +225,11 @@ def build_manifest(
225
225
  ) -> dict[str, Any]:
226
226
  """What `floe manifest generate` writes; `manifest_uri` is a remote `-o` (where it is deployed)."""
227
227
  from floe._cli import _load as _cli_load # pyright: ignore[reportPrivateUsage] -- the CLI's config loading, so both build the same manifest
228
+ from floe._cli import _profile as _cli_profile # pyright: ignore[reportPrivateUsage] -- as above
228
229
  from floe._cli import _realpath # pyright: ignore[reportPrivateUsage] -- as above
229
230
 
230
231
  path = _realpath(config_path)
231
- profile = parse_profile(_realpath(profile_path)) if profile_path else None
232
+ profile = _cli_profile(profile_path)
232
233
  root = _cli_load(path, profile)
233
234
  validate_config(root)
234
235
  options = _manifest.Options(
floe/_cli.py CHANGED
@@ -23,7 +23,7 @@ from floe.errors import FloeConfigError, FloeError, FloeIoError
23
23
  from floe.lineage.observer import run_with_lineage
24
24
  from floe.maintain import FLOOR_DAYS, FLOOR_SNAPSHOTS, ORPHAN_DELAY_DAYS, maintain
25
25
  from floe.manifest_run import load_manifest, shown
26
- from floe.run import select_entities
26
+ from floe.run import domain_entities, select_entities
27
27
  from floe.run.entity import state_uri
28
28
  from floe.state import dump_state, read_state, reset
29
29
  from floe.storage.paths import normalize_local_path, parse_s3_uri
@@ -37,6 +37,12 @@ def _realpath(path: str) -> Path:
37
37
  raise FloeIoError(f"{e.strerror} (os error {e.errno})") from e
38
38
 
39
39
 
40
+ def _profile(location: str | None) -> ProfileConfig | None:
41
+ if not location:
42
+ return None
43
+ return parse_profile(location if location.startswith("s3://") else _realpath(location)) # fmt: skip
44
+
45
+
40
46
  def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
41
47
  variables = (
42
48
  resolve_vars(profile.variables, {}, extract_config_env_vars(path))
@@ -50,7 +56,7 @@ def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
50
56
 
51
57
 
52
58
  def _validate(args: argparse.Namespace) -> int:
53
- profile = parse_profile(_realpath(args.profile)) if args.profile else None
59
+ profile = _profile(args.profile)
54
60
  if args.config is None:
55
61
  if profile is None:
56
62
  raise FloeConfigError(
@@ -75,11 +81,21 @@ def _validate(args: argparse.Namespace) -> int:
75
81
  if e.sink.rejected:
76
82
  print(f" Sink rejected: {e.sink.rejected.format} ({e.sink.rejected.path})") # fmt: skip
77
83
  print(f" Severity: {e.policy.severity}")
78
- profile_arg = f" -p {os.path.realpath(args.profile)}" if args.profile else ""
84
+ profile_arg = ""
85
+ if args.profile:
86
+ profile_arg = f" -p {args.profile if args.profile.startswith('s3://') else os.path.realpath(args.profile)}" # fmt: skip
79
87
  print(f"Next: floe run -c {path}{profile_arg}")
80
88
  return 0
81
89
 
82
90
 
91
+ def _selectors(root: RootConfig, args: argparse.Namespace) -> list[str]:
92
+ if not args.domain:
93
+ return args.entities
94
+ if args.entities:
95
+ raise FloeConfigError("--domain cannot be combined with --entities")
96
+ return domain_entities(root, args.domain)
97
+
98
+
83
99
  def _run(args: argparse.Namespace) -> int:
84
100
  run_id = args.run_id or datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S.%fZ")
85
101
  console = Console(args.log_format, run_id)
@@ -97,7 +113,7 @@ def _run(args: argparse.Namespace) -> int:
97
113
  elif args.config is None:
98
114
  raise FloeConfigError("one of --config or --manifest is required")
99
115
  else:
100
- profile = parse_profile(_realpath(args.profile)) if args.profile else None
116
+ profile = _profile(args.profile)
101
117
  try: # Rust logs a missing config without `run_started`
102
118
  path = _realpath(args.config)
103
119
  except FloeError as e:
@@ -112,7 +128,7 @@ def _run(args: argparse.Namespace) -> int:
112
128
  try:
113
129
  outcome = run_with_lineage(
114
130
  path, root, observed, job, console, validate=args.manifest is None,
115
- run_id=run_id, entities=args.entities, dry_run=args.dry_run, full_refresh=args.full_refresh,
131
+ run_id=run_id, entities=_selectors(root, args), dry_run=args.dry_run, full_refresh=args.full_refresh,
116
132
  config_uri=config_uri,
117
133
  ) # fmt: skip
118
134
  except FloeError as e:
@@ -177,7 +193,7 @@ def _add_entity(args: argparse.Namespace) -> int:
177
193
  def _state(args: argparse.Namespace) -> int:
178
194
  if args.state_command == "reset" and not args.yes:
179
195
  raise FloeConfigError("state reset is destructive, rerun with --yes to confirm")
180
- profile = parse_profile(_realpath(args.profile)) if args.profile else None
196
+ profile = _profile(args.profile)
181
197
  path = _realpath(args.config)
182
198
  root = _load(path, profile)
183
199
  [entity] = select_entities(root, [args.entity])
@@ -199,7 +215,7 @@ def _state(args: argparse.Namespace) -> int:
199
215
 
200
216
 
201
217
  def _maintain(args: argparse.Namespace) -> int:
202
- profile = parse_profile(_realpath(args.profile)) if args.profile else None
218
+ profile = _profile(args.profile)
203
219
  path = _realpath(args.config)
204
220
  results = maintain(
205
221
  path, _load(path, profile), args.entities, args.older_than_days, args.retain_last,
@@ -226,6 +242,7 @@ def _parser() -> argparse.ArgumentParser:
226
242
  run_source = run.add_mutually_exclusive_group()
227
243
  run_source.add_argument("-c", "--config")
228
244
  run_source.add_argument("--manifest")
245
+ run.add_argument("--domain", action="extend", type=lambda s: s.split(","), default=[]) # fmt: skip
229
246
  run.add_argument("--run-id")
230
247
  run.add_argument("-F", "--full-refresh", action="store_true")
231
248
  run.add_argument("--dry-run", action="store_true")
floe/_cli_output.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import json
4
4
  import time
5
- from pathlib import Path
6
5
  from typing import Any
7
6
 
8
7
  from floe.config.types import EntityConfig
@@ -158,11 +157,11 @@ def run_output(
158
157
  timings: list[dict[str, int]],
159
158
  ) -> str:
160
159
  """Rust's `format_run_output`; `mode` is `default`, `quiet` or `verbose`."""
161
- run_dir = Path(outcome.run_dir)
160
+ run_dir = outcome.run_dir # a URI on remote storage: join with `/`, never `Path`
162
161
  head = [f"run id: {outcome.run_id}", f"report base: {outcome.report_base_path}"]
163
162
  if outcome.dry_run is not None:
164
163
  by_id = {e.id: e for e in entities}
165
- blocks = [_preview(by_id[i], files, str(run_dir / id_path(by_id[i]) / "run.json"), mode == "verbose")
164
+ blocks = [_preview(by_id[i], files, f"{run_dir}/{id_path(by_id[i])}/run.json", mode == "verbose")
166
165
  for i, files in outcome.dry_run.items()] # fmt: skip
167
166
  results = dict.fromkeys(("files_total", "rows_total", "accepted_total", "rejected_total", "files_skipped"), 0) # fmt: skip
168
167
  status, exit_code = "success", 0
@@ -182,6 +181,6 @@ def run_output(
182
181
  lines += [
183
182
  f"Totals: files={results['files_total']} rows={results['rows_total']} accepted={results['accepted_total']} rejected={results['rejected_total']}{skipped}",
184
183
  f"Overall: {status} (exit_code={exit_code})",
185
- f"Run summary: {run_dir / 'run.summary.json'}",
184
+ f"Run summary: {run_dir}/run.summary.json",
186
185
  ] # fmt: skip
187
186
  return "\n".join(lines)
floe/config/profile.py CHANGED
@@ -16,13 +16,15 @@ from floe.config.validate import check_lineage
16
16
  from floe.config.vars import string_map
17
17
  from floe.config.yaml import (
18
18
  load_yaml,
19
+ parse_yaml,
19
20
  rust_debug,
20
21
  validate_known_keys,
21
22
  yaml_array,
22
23
  yaml_hash,
23
24
  yaml_string,
24
25
  )
25
- from floe.errors import FloeConfigError
26
+ from floe.errors import FloeConfigError, FloeIoError
27
+ from floe.storage.s3 import read_s3
26
28
 
27
29
  Hash = dict[object, object]
28
30
  Reader = Callable[[Hash, str, str], object]
@@ -65,6 +67,13 @@ def _strings(hash: Hash, key: str, ctx: str) -> list[str] | None:
65
67
  return [yaml_string(item, f"{ctx}.{key}[]") for item in items]
66
68
 
67
69
 
70
+ def _command(hash: Hash, key: str, ctx: str) -> str | list[str] | None:
71
+ """A string, or a list as the manifest and the connectors take it."""
72
+ if isinstance(hash.get(key), list):
73
+ return _strings(hash, key, ctx)
74
+ return _string(hash, key, ctx)
75
+
76
+
68
77
  def _u64(hash: Hash, key: str, ctx: str) -> int | None:
69
78
  if key not in hash:
70
79
  return None
@@ -141,6 +150,23 @@ def _job_scope(hash: Hash, key: str, ctx: str) -> str | None:
141
150
  return scope
142
151
 
143
152
 
153
+ def _str_keyed(value: object, where: str) -> dict[str, object]:
154
+ hash = yaml_hash(value, where)
155
+ validate_known_keys(hash, where, hash.keys()) # every key must be a string
156
+ return cast(dict[str, object], hash)
157
+
158
+
159
+ def _object(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
160
+ return _str_keyed(hash[key], f"{ctx}.{key}") if key in hash else None
161
+
162
+
163
+ def _objects(hash: Hash, key: str, ctx: str) -> list[dict[str, object]] | None:
164
+ if key not in hash:
165
+ return None
166
+ where = f"{ctx}.{key}"
167
+ return [_str_keyed(item, f"{where}[]") for item in yaml_array(hash[key], where)]
168
+
169
+
144
170
  def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
145
171
  if key not in hash:
146
172
  return None
@@ -153,7 +179,7 @@ def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
153
179
 
154
180
  # Rust reads the runner fields in this order, so the first malformed one is the one reported.
155
181
  _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
156
- ("command", _string),
182
+ ("command", _command),
157
183
  ("args", _strings),
158
184
  ("timeout_seconds", _u64),
159
185
  ("ttl_seconds_after_finished", _u64),
@@ -173,6 +199,14 @@ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
173
199
  ("env_parameters", _map),
174
200
  ("job_scope", _job_scope),
175
201
  ("domain_resources", _domain_resources),
202
+ ("labels", _map),
203
+ ("annotations", _map),
204
+ ("image_pull_policy", _string),
205
+ ("security_context", _object),
206
+ ("node_selector", _map),
207
+ ("tolerations", _objects),
208
+ ("backoff_limit", _u64),
209
+ ("env_from", _objects),
176
210
  )
177
211
 
178
212
 
@@ -370,8 +404,17 @@ def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
370
404
  return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
371
405
 
372
406
 
373
- def parse_profile(path: Path) -> ProfileConfig:
374
- docs = load_yaml(path)
407
+ def _docs(location: str | Path) -> list[object]:
408
+ if isinstance(location, Path) or not location.startswith("s3://"):
409
+ return load_yaml(Path(location))
410
+ try:
411
+ return parse_yaml(read_s3(location, "profile").decode())
412
+ except UnicodeDecodeError as e:
413
+ raise FloeIoError("stream did not contain valid UTF-8") from e
414
+
415
+
416
+ def parse_profile(location: str | Path) -> ProfileConfig:
417
+ docs = _docs(location)
375
418
  if not docs:
376
419
  raise FloeConfigError("profile YAML is empty")
377
420
  if len(docs) > 1:
floe/config/storage.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Storage and catalog definitions, ported from `config/parse.rs` (`parse_storages`, `parse_catalogs_with_context`)."""
2
2
 
3
+ from typing import Literal
4
+
3
5
  from pydantic import BaseModel, ConfigDict, SecretStr
4
6
 
5
7
  from floe.config.yaml import (
@@ -37,16 +39,22 @@ class StoragesConfig(_Model):
37
39
 
38
40
 
39
41
  class CatalogDefinition(_Model):
40
- """An Iceberg REST catalog; Glue and Unity are rejected (D1)."""
42
+ """An Iceberg REST or AWS Glue catalog; Unity is rejected (D1)."""
41
43
 
42
44
  name: str
43
- uri: str
44
- credential: SecretStr | None
45
- warehouse: str | None
46
- oauth2_server_uri: str | None
47
- scope: str | None
45
+ type: Literal["rest", "glue"] = "rest"
46
+ # REST only
47
+ uri: str = ""
48
+ credential: SecretStr | None = None
49
+ warehouse: str | None = None
50
+ oauth2_server_uri: str | None = None
51
+ scope: str | None = None
48
52
  warehouse_storage: str | None
49
53
  warehouse_prefix: str | None
54
+ # Glue only; Rust 0.7's `allow_takeover` is accepted and ignored (D42)
55
+ region: str | None = None
56
+ database: str | None = None
57
+ create_database_if_missing: bool = True
50
58
 
51
59
 
52
60
  class CatalogsConfig(_Model):
@@ -88,11 +96,11 @@ _CATALOG_KEYS = (
88
96
  "scope",
89
97
  "warehouse_storage",
90
98
  "warehouse_prefix",
91
- # Glue and Unity keys stay allowed, as in Rust, so a typo reports the key first
92
99
  "region",
93
100
  "database",
94
101
  "create_database_if_missing",
95
102
  "allow_takeover",
103
+ # Unity keys stay allowed, as in Rust, so a typo reports the key first
96
104
  "host",
97
105
  "catalog",
98
106
  "schema",
@@ -127,13 +135,24 @@ def _catalog(value: object, ctx: str) -> CatalogDefinition:
127
135
  validate_known_keys(hash, ctx, _CATALOG_KEYS)
128
136
  name = get_string(hash, "name", ctx)
129
137
  catalog_type = get_string(hash, "type", ctx)
130
- if catalog_type in ("glue", "unity"):
138
+ if catalog_type == "unity":
131
139
  raise _Unsupported(
132
140
  f"{ctx} name={name} type={catalog_type} is not supported in 0.8"
133
141
  )
142
+ if catalog_type == "glue":
143
+ return CatalogDefinition(
144
+ name=name,
145
+ type="glue",
146
+ region=get_string(hash, "region", ctx),
147
+ database=get_string(hash, "database", ctx),
148
+ create_database_if_missing=opt_bool(hash, "create_database_if_missing", ctx)
149
+ is not False,
150
+ warehouse_storage=opt_string(hash, "warehouse_storage", ctx),
151
+ warehouse_prefix=opt_string(hash, "warehouse_prefix", ctx),
152
+ )
134
153
  if catalog_type != "rest":
135
154
  raise FloeConfigError(
136
- f"{ctx} name={name} has unsupported type={catalog_type} (supported: rest)"
155
+ f"{ctx} name={name} has unsupported type={catalog_type} (supported: glue, rest)"
137
156
  )
138
157
  uri = get_string(hash, "uri", ctx)
139
158
  credential = opt_string(hash, "credential", ctx)
floe/config/validate.py CHANGED
@@ -330,7 +330,12 @@ def _check_iceberg(
330
330
  raise _fail(
331
331
  entity, f"sink.accepted.iceberg.catalog references unknown catalog {name}"
332
332
  )
333
- # Unity and Glue are rejected at parse (D1); the registry checked `warehouse_storage`.
333
+ if definition.type == "glue" and "{domain}" in (definition.database or "") and not (iceberg.namespace or entity.domain): # fmt: skip
334
+ raise _fail(entity, f"sink.accepted.iceberg.namespace or domain is required for catalog {name} database containing {{domain}}") # fmt: skip
335
+ # Unity is rejected at parse (D1); the registry checked `warehouse_storage`.
336
+ if definition.type == "glue" and entity.sink.write_mode.startswith("merge"):
337
+ # ponytail: merges run through DuckDB, attached to REST catalogs only; attach Glue when a merge needs it
338
+ raise _fail(entity, f"sink.write_mode={entity.sink.write_mode} requires a rest catalog (glue supports overwrite and append)") # fmt: skip
334
339
  if definition.warehouse_storage is None:
335
340
  kind = _storage_type(config, accepted_storage)
336
341
  if kind not in ("s3", "gcs"):
@@ -708,7 +713,13 @@ def _check_storages(config: RootConfig) -> None:
708
713
  f"catalogs.definitions name={c.name} warehouse_storage "
709
714
  f"references unknown storage {wh}"
710
715
  )
711
- if (kind := _storage_type(config, wh)) not in ("s3", "gcs"):
716
+ kind = _storage_type(config, wh)
717
+ if c.type == "glue" and kind != "s3":
718
+ raise FloeConfigError(
719
+ f"catalogs.definitions name={c.name} warehouse_storage must "
720
+ f"reference s3 storage for glue catalog (got {kind})"
721
+ )
722
+ if kind not in ("s3", "gcs"):
712
723
  raise FloeConfigError(
713
724
  f"catalogs.definitions name={c.name} warehouse_storage must "
714
725
  f"reference s3 or gcs storage for rest catalog (got {kind})"
floe/iceberg/catalog.py CHANGED
@@ -74,12 +74,14 @@ def _credential_props(catalog: str, credential: str) -> dict[str, str]:
74
74
  def open_catalog(
75
75
  definition: CatalogDefinition, storage: StorageDefinition | None = None
76
76
  ) -> Catalog:
77
- """Credentials for S3 come from the environment; the catalog never vends them (as in Rust)."""
77
+ """Credentials for S3 and Glue come from the environment; the catalog never vends them (as in Rust)."""
78
78
  props = {
79
79
  "type": "rest",
80
80
  "uri": definition.uri,
81
81
  "header.X-Iceberg-Access-Delegation": "",
82
82
  }
83
+ if definition.type == "glue":
84
+ props = {"type": "glue", "glue.region": definition.region or ""}
83
85
  if definition.warehouse is not None:
84
86
  props["warehouse"] = definition.warehouse
85
87
  if definition.credential is not None:
@@ -99,7 +101,7 @@ def open_catalog(
99
101
  props["s3.force-virtual-addressing"] = str(
100
102
  not storage.path_style_access
101
103
  ).lower()
102
- with run_error("rest catalog init failed"):
104
+ with run_error(f"{definition.type} catalog init failed"):
103
105
  return load_catalog(definition.name, **props)
104
106
 
105
107
 
@@ -167,6 +169,9 @@ def open_duckdb(
167
169
  definition: CatalogDefinition, storage: StorageDefinition | None = None
168
170
  ) -> duckdb.DuckDBPyConnection:
169
171
  """DuckDB with the catalog attached as `DUCKDB_CATALOG`."""
172
+ # validation rejects Glue merges; compaction lands here too
173
+ if definition.type != "rest":
174
+ raise FloeConfigError(f"iceberg catalog {definition.name}: merges and compaction require a rest catalog (got {definition.type})") # fmt: skip
170
175
  statements = _duckdb_setup(definition, storage)
171
176
  with run_error("duckdb catalog attach failed"):
172
177
  # ponytail: DuckDB's default memory_limit and spill dir; set the ADR's limits when the run wires merges
@@ -292,8 +297,9 @@ def ensure_table(
292
297
  location: str | None = None,
293
298
  partition_fields: PartitionFields | None = None,
294
299
  schema_evolution: SchemaEvolutionMode = "strict",
300
+ create_namespace: bool = True,
295
301
  ) -> Table:
296
- """Create the namespace and table if missing; otherwise check the table matches (Rust's append path).
302
+ """Create the namespace (unless `create_namespace` is off) and table if missing; otherwise check the table matches (Rust's append path).
297
303
 
298
304
  With `add_columns`, columns new in `schema` are added as optional (D13); partition changes stay rejected.
299
305
  """
@@ -301,6 +307,8 @@ def ensure_table(
301
307
  with run_error("rest catalog namespace_exists check failed"):
302
308
  namespace_exists = catalog.namespace_exists(ident)
303
309
  if not namespace_exists:
310
+ if not create_namespace:
311
+ raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
304
312
  with run_error("rest catalog namespace create failed"):
305
313
  catalog.create_namespace(ident)
306
314
  identifier = (namespace, name)
floe/iceberg/sink.py CHANGED
@@ -70,7 +70,12 @@ def resolve_target(storages: StoragesConfig | None, catalogs: CatalogsConfig | N
70
70
  catalog = next((c for c in (catalogs.definitions if catalogs else []) if c.name == name), None) # fmt: skip
71
71
  if catalog is None: # validated; kept for callers that skip validation
72
72
  raise FloeConfigError(f"entity.name={entity.name} sink.accepted.iceberg.catalog references unknown catalog {name}") # fmt: skip
73
- namespace = catalog_ident(sink.iceberg.namespace or entity.domain or "default")
73
+ # what `{domain}` in the database stands for (validated: set when the database has the token)
74
+ scope = sink.iceberg.namespace or entity.domain
75
+ database = catalog.database
76
+ if database is not None:
77
+ database = database.replace("{domain}", catalog_ident(scope or ""))
78
+ namespace = catalog_ident(scope or database or "default")
74
79
  table = catalog_ident(sink.iceberg.table or entity.name)
75
80
  field = f"entity.name={entity.name} sink.accepted.storage"
76
81
  accepted_uri, storage = resolve_path(storages, config_dir, sink.storage, sink.path, field) # fmt: skip
@@ -82,6 +87,9 @@ def resolve_target(storages: StoragesConfig | None, catalogs: CatalogsConfig | N
82
87
  location, storage = resolve_path(storages, config_dir, where, "/".join(p for p in parts if p), field) # fmt: skip
83
88
  else:
84
89
  location = accepted_uri
90
+ # Rust: the Glue database holds the table; the namespace only names its location
91
+ if database is not None:
92
+ namespace = database
85
93
  return IcebergTarget(catalog, storage, namespace, table, location, accepted_uri) # fmt: skip
86
94
 
87
95
 
@@ -169,7 +177,7 @@ def write_iceberg(
169
177
  raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
170
178
  table = ensure_table(
171
179
  catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
172
- partition_fields(entity), evolution.mode if evolution else "strict",
180
+ partition_fields(entity), evolution.mode if evolution else "strict", target.catalog.create_database_if_missing,
173
181
  ) # fmt: skip
174
182
  merge: dict[str, Any] | None = None
175
183
  if mode in ("merge_scd1", "merge_scd2"):
floe/manifest.py CHANGED
@@ -26,6 +26,7 @@ from floe.errors import FloeConfigError, FloeError
26
26
  from floe.pii import PiiColumn
27
27
  from floe.run import select_entities
28
28
  from floe.storage.paths import normalize_local_path
29
+ from floe.storage.s3 import read_s3
29
30
 
30
31
  Json = dict[str, Any]
31
32
  _REMOTE = ("s3://", "gs://", "gcs://", "abfs://", "abfss://", "az://")
@@ -274,7 +275,10 @@ _RUNNER_FIELDS = (
274
275
  *("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
275
276
  *("python_file_uri", "job_name", "auth", "env_parameters"),
276
277
  )
277
- _PYTHON_RUNNER_FIELDS = ("job_scope", "domain_resources") # carried only when set
278
+ _PYTHON_RUNNER_FIELDS = ( # carried only when set
279
+ *("job_scope", "domain_resources", "labels", "annotations", "image_pull_policy"),
280
+ *("security_context", "node_selector", "tolerations", "backoff_limit", "env_from"),
281
+ )
278
282
 
279
283
 
280
284
  # Rust `runners_contract`: the fields each profile runner type carries into the manifest
@@ -351,6 +355,13 @@ def _blocks(root: RootConfig) -> Json:
351
355
  {
352
356
  "name": c.name,
353
357
  "type_config": {
358
+ "type": "glue",
359
+ "region": c.region,
360
+ "database": c.database,
361
+ "create_database_if_missing": c.create_database_if_missing,
362
+ }
363
+ if c.type == "glue"
364
+ else {
354
365
  "type": "rest",
355
366
  "uri": c.uri,
356
367
  "credential": c.credential
@@ -452,9 +463,14 @@ def build_manifest(
452
463
  report = _Target(ctx, root.report)
453
464
  profile_uri = profile_checksum = None
454
465
  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())
466
+ # an s3 profile is read from there by the run, so it is recorded as typed
467
+ if options.profile_arg.startswith("s3://"):
468
+ profile_uri = options.profile_arg
469
+ profile_checksum = _sha256(read_s3(profile_uri, "profile"))
470
+ else:
471
+ profile = Path(os.path.realpath(options.profile_arg))
472
+ profile_uri = _config_uri(options.profile_arg, profile, work_root)
473
+ profile_checksum = _sha256(profile.read_bytes())
458
474
  manifest: Json = _put(
459
475
  {
460
476
  "generated_at_ts_ms": 0,
floe/manifest_run.py CHANGED
@@ -38,7 +38,7 @@ from floe.config.types import (
38
38
  from floe.config.validate import FLOE_VERSION
39
39
  from floe.errors import FloeConfigError, FloeIoError
40
40
  from floe.storage.paths import normalize_local_path, parse_s3_uri
41
- from floe.storage.s3 import S3Client
41
+ from floe.storage.s3 import read_s3
42
42
 
43
43
  Json = dict[str, Any]
44
44
  _M = TypeVar("_M", bound=BaseModel)
@@ -180,8 +180,7 @@ def _storage_def(d: Json) -> Json:
180
180
 
181
181
 
182
182
  def _catalog_def(d: Json) -> Json:
183
- rest = {k: v for k, v in d["type_config"].items() if k != "type"} # D1: REST only
184
- return {k: v for k, v in d.items() if k != "type_config"} | rest
183
+ return {k: v for k, v in d.items() if k != "type_config"} | d["type_config"]
185
184
 
186
185
 
187
186
  def _report(base: str, storages: StoragesConfig | None) -> tuple[ReportConfig, StoragesConfig | None]: # fmt: skip
@@ -226,12 +225,8 @@ def _named_local(m: Json) -> bool:
226
225
 
227
226
 
228
227
  def _read(location: str) -> bytes:
229
- # S3 settings come from the environment (AWS_ENDPOINT_URL, ...), as for `-o s3://`
230
228
  if location.startswith("s3://"):
231
- found = S3Client(parse_s3_uri(location)[0]).read(location)
232
- if found is None:
233
- raise FloeIoError(f"manifest not found: {location}")
234
- return found.body
229
+ return read_s3(location, "manifest")
235
230
  if "://" in location: # D1: gcs and adls are dropped
236
231
  raise FloeConfigError(f"remote manifest is not supported: {location}")
237
232
  try:
floe/run/__init__.py CHANGED
@@ -59,6 +59,14 @@ def select_entities(root: RootConfig, selectors: Sequence[str]) -> list[EntityCo
59
59
  return [e for i, e in enumerate(root.entities) if i in picked]
60
60
 
61
61
 
62
+ def domain_entities(root: RootConfig, domains: Sequence[str]) -> list[str]:
63
+ """Ids of the entities of these domains, as `select_entities` selectors."""
64
+ missing = [d for d in dict.fromkeys(domains) if all(e.domain != d for e in root.entities)] # fmt: skip
65
+ if missing:
66
+ raise FloeConfigError(f"domains not found: {', '.join(missing)}")
67
+ return [e.id for e in root.entities if e.domain in domains]
68
+
69
+
62
70
  def _rfc3339() -> str:
63
71
  return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
64
72
 
floe/storage/s3.py CHANGED
@@ -9,7 +9,7 @@ import boto3
9
9
  from botocore.config import Config
10
10
  from botocore.exceptions import BotoCoreError, ClientError
11
11
 
12
- from floe.errors import FloeStorageError
12
+ from floe.errors import FloeIoError, FloeStorageError
13
13
  from floe.storage.paths import fnv1a_hex, parse_s3_uri
14
14
  from floe.storage.types import ObjectRef, StoredObject
15
15
 
@@ -180,3 +180,11 @@ class S3Client:
180
180
  raise FloeStorageError(f"s3 delete object failed: {e}") from e
181
181
  except BotoCoreError as e:
182
182
  raise FloeStorageError(f"s3 delete object failed: {e}") from e
183
+
184
+
185
+ def read_s3(uri: str, what: str) -> bytes:
186
+ # S3 settings come from the environment (AWS_ENDPOINT_URL, ...), as for `-o s3://`
187
+ found = S3Client(parse_s3_uri(uri)[0]).read(uri)
188
+ if found is None:
189
+ raise FloeIoError(f"{what} not found: {uri}")
190
+ return found.body
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: boto3>=1.43.118
7
+ Requires-Dist: boto3>=1.36.0
8
8
  Requires-Dist: duckdb==1.5.6
9
9
  Requires-Dist: fastexcel>=0.21.0
10
10
  Requires-Dist: polars>=2.0.0
@@ -1,13 +1,13 @@
1
- floe/__init__.py,sha256=VkSVWF7vzIICTkxaNI2SVXfD2JQmrHtV_NiTBCUwzaM,13221
1
+ floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
2
2
  floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
3
- floe/_cli.py,sha256=2ecJIMELxfXkm3ISzvQsS-CnnjUBj1Kqh9HANlUlMCM,13549
4
- floe/_cli_output.py,sha256=fwUqMkBvUEojri8WnXGzTIHMZLcoOHQfByeOz5L6ugA,8792
3
+ floe/_cli.py,sha256=6zQRRczAgARgc7PcLrWS7lEzJa6GKsbpf8Zz8GsDjFo,14081
4
+ floe/_cli_output.py,sha256=VLxWQr-EPtIwklo9xr2CnjMD17U-7JrMUWdTtAP3jrY,8809
5
5
  floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
6
6
  floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
7
7
  floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
8
8
  floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
9
- floe/manifest.py,sha256=HV3y-WzZeorVpTAGrNU9LuZCbvX8ALfGC9J3Q2jtnYY,19425
10
- floe/manifest_run.py,sha256=8m4LXaymkw3ZX706sTjZK5L1hOYDT231CWl9tjoBe1c,12700
9
+ floe/manifest.py,sha256=GDaa01K9n5vxI0kRQSkM_xjjIwoeF6JN-N1Fktas8Xc,20216
10
+ floe/manifest_run.py,sha256=1XLInkpaCofMezMBs_5uIFoR5W7lbwCBFZeqRcnwUAw,12398
11
11
  floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
12
12
  floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
@@ -20,17 +20,17 @@ floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
21
21
  floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
22
22
  floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
23
- floe/config/profile.py,sha256=xlLbrMbD_prJ0UteoSUNwbmjOq5NMm6XmQvxQNg4WxI,14035
23
+ floe/config/profile.py,sha256=mYbyFse2pBUFuV5TMtl0RFrfyPUnJhdklbAX5eSAM88,15564
24
24
  floe/config/project.py,sha256=zgTOhTDlbMr1chViMEZyv0drozZdumTaIoKDL0vv9sw,14141
25
- floe/config/storage.py,sha256=Yjia1mfxWvIq7DpS24AFSCQDeJ4aqxY9s76WDDrqMv4,5452
25
+ floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
26
26
  floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
27
- floe/config/validate.py,sha256=4m71oZgvKFH61Ts6TASlLZ_JDnPEjWo4cfGcoJOLGQY,34963
27
+ floe/config/validate.py,sha256=-cDn9CKfR1BmAATgYy1PWlTWSkpu4mjt4PMKNL7k2FE,35858
28
28
  floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
29
29
  floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
30
- floe/iceberg/catalog.py,sha256=nRRC4VyjKEXATlifmlE2394e8b5vAO_HiIp0LugS5tI,13819
30
+ floe/iceberg/catalog.py,sha256=TeINK-jVZJAurROEeovVNK3s4PY-czeWih1y_KWohP8,14455
31
31
  floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
- floe/iceberg/sink.py,sha256=2pWrixvqC9_ZNKvGAJju4-TfKOqFoYR28agllUt9AXA,10804
33
+ floe/iceberg/sink.py,sha256=uE0jLhhxRFOeKi7ChAzstKexy1ZrovE4EKATGTYn-Gk,11255
34
34
  floe/iceberg/unique.py,sha256=PKUFEcCkU71k66__KfUE7NTos73ZYlZbb8GJPn0yFoo,2958
35
35
  floe/iceberg/write.py,sha256=zrFeeJu-YT4Pxo9u-fRujUtFb1Epx1KFoq2qFyP1iGs,11036
36
36
  floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
@@ -45,7 +45,7 @@ floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
45
45
  floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
46
46
  floe/report/entity.py,sha256=ekuB-1JDATvxIFnBTCQpku8uXtrSQNwvryjdTidXk6I,9707
47
47
  floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
48
- floe/run/__init__.py,sha256=PON2NYpvsRej6cXBPP8GDRZMcinVTtnKpwLT93Vzg0c,7687
48
+ floe/run/__init__.py,sha256=6teCk4GZoX7grwtyhQUbqwbx_TSQebzViUJA19CmEl4,8107
49
49
  floe/run/entity.py,sha256=4UGhUtjYR84MOe3jjwFmLGWAaDln1z5KOHC0MOvDLpQ,45204
50
50
  floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
51
51
  floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -55,12 +55,12 @@ floe/storage/local.py,sha256=NKjBf_-PkDnbZ3FzXWE7KZ1xY0G_o3tRy0_7nxW7FLs,6307
55
55
  floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
56
56
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
57
57
  floe/storage/resolve.py,sha256=pFAuVytTfnSEcTAVlJjFTj7S_Vipjry1ormWZP3Egmc,2593
58
- floe/storage/s3.py,sha256=iWLDr3-qFH-Of3HKUiaKFD-1HQRip4FSQ-q9xxv7VNI,7212
58
+ floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
59
59
  floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
60
60
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
61
  floe/write/outputs.py,sha256=5LgN_8PMPPOgkRVZw_KvxYXmc02-meL7iZLeMKVtufQ,4932
62
- floe_python-0.8.0.dist-info/METADATA,sha256=jQyy8j3vSQ6Qs047OQASPHOEnq5UV02wpXavZ3MHf7U,444
63
- floe_python-0.8.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.0.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.0.dist-info/RECORD,,
62
+ floe_python-0.8.2.dist-info/METADATA,sha256=O9qNqOIcnbGRrkwUAcPGKh35ayNq0bG4YwZMpQ-z90g,442
63
+ floe_python-0.8.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
+ floe_python-0.8.2.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
+ floe_python-0.8.2.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
+ floe_python-0.8.2.dist-info/RECORD,,