floe-python 0.8.0__py3-none-any.whl → 0.8.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 +3 -2
- floe/_cli.py +24 -7
- floe/_cli_output.py +3 -4
- floe/config/profile.py +47 -4
- floe/config/storage.py +28 -9
- floe/config/validate.py +11 -2
- floe/iceberg/catalog.py +11 -3
- floe/iceberg/sink.py +7 -2
- floe/manifest.py +20 -4
- floe/manifest_run.py +3 -8
- floe/run/__init__.py +8 -0
- floe/storage/s3.py +9 -1
- {floe_python-0.8.0.dist-info → floe_python-0.8.1.dist-info}/METADATA +2 -2
- {floe_python-0.8.0.dist-info → floe_python-0.8.1.dist-info}/RECORD +17 -17
- {floe_python-0.8.0.dist-info → floe_python-0.8.1.dist-info}/WHEEL +0 -0
- {floe_python-0.8.0.dist-info → floe_python-0.8.1.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.0.dist-info → floe_python-0.8.1.dist-info}/licenses/LICENSE +0 -0
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
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",
|
|
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
|
|
374
|
-
|
|
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
|
|
42
|
+
"""An Iceberg REST or AWS Glue catalog; Unity is rejected (D1)."""
|
|
41
43
|
|
|
42
44
|
name: str
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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,10 @@ def _check_iceberg(
|
|
|
330
330
|
raise _fail(
|
|
331
331
|
entity, f"sink.accepted.iceberg.catalog references unknown catalog {name}"
|
|
332
332
|
)
|
|
333
|
-
# Unity
|
|
333
|
+
# Unity is rejected at parse (D1); the registry checked `warehouse_storage`.
|
|
334
|
+
if definition.type == "glue" and entity.sink.write_mode.startswith("merge"):
|
|
335
|
+
# ponytail: merges run through DuckDB, attached to REST catalogs only; attach Glue when a merge needs it
|
|
336
|
+
raise _fail(entity, f"sink.write_mode={entity.sink.write_mode} requires a rest catalog (glue supports overwrite and append)") # fmt: skip
|
|
334
337
|
if definition.warehouse_storage is None:
|
|
335
338
|
kind = _storage_type(config, accepted_storage)
|
|
336
339
|
if kind not in ("s3", "gcs"):
|
|
@@ -708,7 +711,13 @@ def _check_storages(config: RootConfig) -> None:
|
|
|
708
711
|
f"catalogs.definitions name={c.name} warehouse_storage "
|
|
709
712
|
f"references unknown storage {wh}"
|
|
710
713
|
)
|
|
711
|
-
|
|
714
|
+
kind = _storage_type(config, wh)
|
|
715
|
+
if c.type == "glue" and kind != "s3":
|
|
716
|
+
raise FloeConfigError(
|
|
717
|
+
f"catalogs.definitions name={c.name} warehouse_storage must "
|
|
718
|
+
f"reference s3 storage for glue catalog (got {kind})"
|
|
719
|
+
)
|
|
720
|
+
if kind not in ("s3", "gcs"):
|
|
712
721
|
raise FloeConfigError(
|
|
713
722
|
f"catalogs.definitions name={c.name} warehouse_storage must "
|
|
714
723
|
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("
|
|
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,9 @@ 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(
|
|
73
|
+
namespace = catalog_ident(
|
|
74
|
+
sink.iceberg.namespace or entity.domain or catalog.database or "default"
|
|
75
|
+
)
|
|
74
76
|
table = catalog_ident(sink.iceberg.table or entity.name)
|
|
75
77
|
field = f"entity.name={entity.name} sink.accepted.storage"
|
|
76
78
|
accepted_uri, storage = resolve_path(storages, config_dir, sink.storage, sink.path, field) # fmt: skip
|
|
@@ -82,6 +84,9 @@ def resolve_target(storages: StoragesConfig | None, catalogs: CatalogsConfig | N
|
|
|
82
84
|
location, storage = resolve_path(storages, config_dir, where, "/".join(p for p in parts if p), field) # fmt: skip
|
|
83
85
|
else:
|
|
84
86
|
location = accepted_uri
|
|
87
|
+
# Rust: the Glue database holds the table; the namespace only names its location
|
|
88
|
+
if catalog.database is not None:
|
|
89
|
+
namespace = catalog.database
|
|
85
90
|
return IcebergTarget(catalog, storage, namespace, table, location, accepted_uri) # fmt: skip
|
|
86
91
|
|
|
87
92
|
|
|
@@ -169,7 +174,7 @@ def write_iceberg(
|
|
|
169
174
|
raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
|
|
170
175
|
table = ensure_table(
|
|
171
176
|
catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
|
|
172
|
-
partition_fields(entity), evolution.mode if evolution else "strict",
|
|
177
|
+
partition_fields(entity), evolution.mode if evolution else "strict", target.catalog.create_database_if_missing,
|
|
173
178
|
) # fmt: skip
|
|
174
179
|
merge: dict[str, Any] | None = None
|
|
175
180
|
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 = (
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
Version: 0.8.1
|
|
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.
|
|
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=
|
|
1
|
+
floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
|
|
2
2
|
floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
|
|
3
|
-
floe/_cli.py,sha256=
|
|
4
|
-
floe/_cli_output.py,sha256=
|
|
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=
|
|
10
|
-
floe/manifest_run.py,sha256=
|
|
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=
|
|
23
|
+
floe/config/profile.py,sha256=mYbyFse2pBUFuV5TMtl0RFrfyPUnJhdklbAX5eSAM88,15564
|
|
24
24
|
floe/config/project.py,sha256=zgTOhTDlbMr1chViMEZyv0drozZdumTaIoKDL0vv9sw,14141
|
|
25
|
-
floe/config/storage.py,sha256=
|
|
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=
|
|
27
|
+
floe/config/validate.py,sha256=MRwGztx0xbcqGjpIKjfCMDMeyTz-pd4Mr9XrfyiW5CQ,35571
|
|
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=
|
|
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=
|
|
33
|
+
floe/iceberg/sink.py,sha256=854HAAz4UmsgBGR6TrBy3tcqWk-ss1RcuLK-ynQe0gA,11040
|
|
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=
|
|
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=
|
|
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.
|
|
63
|
-
floe_python-0.8.
|
|
64
|
-
floe_python-0.8.
|
|
65
|
-
floe_python-0.8.
|
|
66
|
-
floe_python-0.8.
|
|
62
|
+
floe_python-0.8.1.dist-info/METADATA,sha256=t7zQL0mXQAhLksYIkXMkuuYolrQL3046WRDPjZurnQI,442
|
|
63
|
+
floe_python-0.8.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
64
|
+
floe_python-0.8.1.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
65
|
+
floe_python-0.8.1.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
66
|
+
floe_python-0.8.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|