floe-python 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/config/parse.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""`parse_config`, ported from `config/parse.rs` and `config/template.rs` (`apply_templates_with_vars`)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, SecretStr
|
|
8
|
+
|
|
9
|
+
from floe.config.include import assemble_includes
|
|
10
|
+
from floe.config.project import RootConfig, parse_root
|
|
11
|
+
from floe.config.types import EntityConfig
|
|
12
|
+
from floe.config.vars import build_env_vars, replace_placeholders
|
|
13
|
+
from floe.config.yaml import load_single_doc, yaml_array, yaml_hash
|
|
14
|
+
from floe.errors import FloeConfigError
|
|
15
|
+
|
|
16
|
+
Vars = Mapping[str, str]
|
|
17
|
+
M = TypeVar("M", bound=BaseModel)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fill(model: M, names: tuple[str, ...], variables: Vars, label: str) -> M:
|
|
21
|
+
"""Substitute placeholders in the `names` fields that are set; `label` prefixes the field in errors."""
|
|
22
|
+
update: dict[str, Any] = {}
|
|
23
|
+
for name in names:
|
|
24
|
+
value = getattr(model, name)
|
|
25
|
+
secret = isinstance(value, SecretStr)
|
|
26
|
+
if secret:
|
|
27
|
+
value = value.get_secret_value()
|
|
28
|
+
if value is not None:
|
|
29
|
+
filled = replace_placeholders(value, variables, label + name)
|
|
30
|
+
update[name] = SecretStr(filled) if secret else filled
|
|
31
|
+
return model.model_copy(update=update)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fill_entity(
|
|
35
|
+
entity: EntityConfig, variables: Vars, domains: Mapping[str, str | None]
|
|
36
|
+
) -> EntityConfig:
|
|
37
|
+
context = dict(variables) | {"entity.name": entity.name}
|
|
38
|
+
if entity.domain is not None:
|
|
39
|
+
if entity.domain not in domains:
|
|
40
|
+
raise FloeConfigError(
|
|
41
|
+
f"entity.name={entity.name} references unknown domain {entity.domain}"
|
|
42
|
+
)
|
|
43
|
+
context.pop("domain.incoming_dir", None)
|
|
44
|
+
if (incoming_dir := domains[entity.domain]) is not None:
|
|
45
|
+
context["domain.incoming_dir"] = incoming_dir
|
|
46
|
+
context["domain.name"] = entity.domain
|
|
47
|
+
|
|
48
|
+
def sub(path: str, field: str) -> str:
|
|
49
|
+
return replace_placeholders(path, context, field, entity.name)
|
|
50
|
+
|
|
51
|
+
sink = entity.sink
|
|
52
|
+
accepted = sink.accepted.model_copy(
|
|
53
|
+
update={"path": sub(sink.accepted.path, "entities.sink.accepted.path")}
|
|
54
|
+
)
|
|
55
|
+
rejected, archive = sink.rejected, sink.archive
|
|
56
|
+
if rejected is not None:
|
|
57
|
+
rejected = rejected.model_copy(
|
|
58
|
+
update={"path": sub(rejected.path, "entities.sink.rejected.path")}
|
|
59
|
+
)
|
|
60
|
+
if archive is not None:
|
|
61
|
+
archive = archive.model_copy(
|
|
62
|
+
update={"path": sub(archive.path, "entities.sink.archive.path")}
|
|
63
|
+
)
|
|
64
|
+
return entity.model_copy(
|
|
65
|
+
update={
|
|
66
|
+
"source": entity.source.model_copy(
|
|
67
|
+
update={"path": sub(entity.source.path, "entities.source.path")}
|
|
68
|
+
),
|
|
69
|
+
"sink": sink.model_copy(
|
|
70
|
+
update={"accepted": accepted, "rejected": rejected, "archive": archive}
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_templates(
|
|
77
|
+
config: RootConfig, config_dir: Path, profile_vars: Vars
|
|
78
|
+
) -> RootConfig:
|
|
79
|
+
env = config.env
|
|
80
|
+
variables = build_env_vars(
|
|
81
|
+
config_dir, env.file if env else None, env.vars if env else {}, profile_vars
|
|
82
|
+
)
|
|
83
|
+
domains: list[Any] = []
|
|
84
|
+
lookup: dict[str, str | None] = {}
|
|
85
|
+
for domain in config.domains:
|
|
86
|
+
resolved = (
|
|
87
|
+
None
|
|
88
|
+
if domain.incoming_dir is None
|
|
89
|
+
else replace_placeholders(
|
|
90
|
+
domain.incoming_dir, variables, "domains.incoming_dir"
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
domains.append(domain.model_copy(update={"resolved_incoming_dir": resolved}))
|
|
94
|
+
if domain.name in lookup:
|
|
95
|
+
raise FloeConfigError(f"duplicate domain name {domain.name}")
|
|
96
|
+
lookup[domain.name] = resolved
|
|
97
|
+
|
|
98
|
+
report = _fill(config.report, ("path",), variables, "report.")
|
|
99
|
+
lineage = config.lineage
|
|
100
|
+
if lineage is not None:
|
|
101
|
+
lineage = _fill(
|
|
102
|
+
lineage,
|
|
103
|
+
("url", "endpoint", "api_key", "dataset_namespace"),
|
|
104
|
+
variables,
|
|
105
|
+
"lineage.",
|
|
106
|
+
)
|
|
107
|
+
storages = config.storages
|
|
108
|
+
if storages is not None:
|
|
109
|
+
names = ("bucket", "region", "account", "container", "prefix", "endpoint")
|
|
110
|
+
definitions = [
|
|
111
|
+
_fill(d, names, variables, f"storages.definitions.{d.name}.")
|
|
112
|
+
for d in storages.definitions
|
|
113
|
+
]
|
|
114
|
+
storages = storages.model_copy(update={"definitions": definitions})
|
|
115
|
+
return config.model_copy(
|
|
116
|
+
update={
|
|
117
|
+
"domains": domains,
|
|
118
|
+
"report": report,
|
|
119
|
+
"lineage": lineage,
|
|
120
|
+
"storages": storages,
|
|
121
|
+
"entities": [_fill_entity(e, variables, lookup) for e in config.entities],
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_config(
|
|
127
|
+
path: Path, profile_vars: Vars | None = None, extra_entity: object = None
|
|
128
|
+
) -> RootConfig:
|
|
129
|
+
"""Load a config and its includes, then apply the template pass.
|
|
130
|
+
|
|
131
|
+
`extra_entity` is appended to the entities, so `add-entity` validates an entity before writing it.
|
|
132
|
+
"""
|
|
133
|
+
doc = load_single_doc(path)
|
|
134
|
+
config_files = [path]
|
|
135
|
+
origins = assemble_includes(doc, path.parent, config_files)
|
|
136
|
+
if extra_entity is not None:
|
|
137
|
+
entities = yaml_hash(doc, "root").setdefault("entities", [])
|
|
138
|
+
yaml_array(entities, "entities").append(extra_entity)
|
|
139
|
+
config = parse_root(doc, origins).model_copy(update={"config_files": config_files})
|
|
140
|
+
return _apply_templates(config, path.parent, profile_vars or {})
|
floe/config/profile.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from floe.config.project import LineageConfig, RootConfig, parse_lineage
|
|
9
|
+
from floe.config.storage import (
|
|
10
|
+
CatalogsConfig,
|
|
11
|
+
StoragesConfig,
|
|
12
|
+
parse_catalogs,
|
|
13
|
+
parse_storages,
|
|
14
|
+
)
|
|
15
|
+
from floe.config.validate import check_lineage
|
|
16
|
+
from floe.config.vars import string_map
|
|
17
|
+
from floe.config.yaml import (
|
|
18
|
+
load_yaml,
|
|
19
|
+
rust_debug,
|
|
20
|
+
validate_known_keys,
|
|
21
|
+
yaml_array,
|
|
22
|
+
yaml_hash,
|
|
23
|
+
yaml_string,
|
|
24
|
+
)
|
|
25
|
+
from floe.errors import FloeConfigError
|
|
26
|
+
|
|
27
|
+
Hash = dict[object, object]
|
|
28
|
+
Reader = Callable[[Hash, str, str], object]
|
|
29
|
+
|
|
30
|
+
API_VERSION = "floe/v1"
|
|
31
|
+
KIND = "EnvironmentProfile"
|
|
32
|
+
RUNNERS = ("local", "kubernetes_job", "databricks_job")
|
|
33
|
+
CONTRACT = {
|
|
34
|
+
"kubernetes_job": ("image", "namespace"),
|
|
35
|
+
"databricks_job": (
|
|
36
|
+
*("workspace_url", "existing_cluster_id", "config_uri", "python_file_uri"),
|
|
37
|
+
"auth.service_principal_oauth_ref",
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ProfileConfig:
|
|
44
|
+
name: str
|
|
45
|
+
runner: dict[str, object] # `type` and every field, None when unset
|
|
46
|
+
variables: dict[str, str]
|
|
47
|
+
catalogs: CatalogsConfig | None
|
|
48
|
+
storages: StoragesConfig | None
|
|
49
|
+
lineage: LineageConfig | None
|
|
50
|
+
orchestration: dict[str, object] | None = None # its set fields only, as Rust
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _string(hash: Hash, key: str, ctx: str, required: bool = False) -> str | None:
|
|
54
|
+
if key not in hash:
|
|
55
|
+
if required:
|
|
56
|
+
raise FloeConfigError(f"{ctx}.{key} is required")
|
|
57
|
+
return None
|
|
58
|
+
return yaml_string(hash[key], f"{ctx}.{key}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strings(hash: Hash, key: str, ctx: str) -> list[str] | None:
|
|
62
|
+
if key not in hash:
|
|
63
|
+
return None
|
|
64
|
+
items = yaml_array(hash[key], f"{ctx}.{key}")
|
|
65
|
+
return [yaml_string(item, f"{ctx}.{key}[]") for item in items]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _u64(hash: Hash, key: str, ctx: str) -> int | None:
|
|
69
|
+
if key not in hash:
|
|
70
|
+
return None
|
|
71
|
+
value = hash[key]
|
|
72
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
73
|
+
raise FloeConfigError(f"{ctx}.{key} must be a non-negative integer")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _map(hash: Hash, key: str, ctx: str) -> dict[str, str] | None:
|
|
78
|
+
return string_map(hash[key], f"{ctx}.{key}") if key in hash else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _secrets(hash: Hash, key: str, ctx: str) -> list[dict[str, str | None]] | None:
|
|
82
|
+
if key not in hash:
|
|
83
|
+
return None
|
|
84
|
+
where = f"{ctx}.{key}[]"
|
|
85
|
+
secrets: list[dict[str, str | None]] = []
|
|
86
|
+
for item in yaml_array(hash[key], f"{ctx}.{key}"):
|
|
87
|
+
secret = yaml_hash(item, where)
|
|
88
|
+
validate_known_keys(secret, where, ("name", "secret_name", "key"))
|
|
89
|
+
fields = ("name", "secret_name", "key")
|
|
90
|
+
secrets.append({f: _string(secret, f, where, True) for f in fields})
|
|
91
|
+
return secrets
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resources(
|
|
95
|
+
hash: Hash, key: str, ctx: str, per_domain: bool = False
|
|
96
|
+
) -> dict[str, object] | None:
|
|
97
|
+
if key not in hash:
|
|
98
|
+
return None
|
|
99
|
+
where = f"{ctx}.{key}"
|
|
100
|
+
resources = yaml_hash(hash[key], where)
|
|
101
|
+
validate_known_keys(
|
|
102
|
+
resources, where, ("cpu", "memory_mb", "cpu_limit", "memory_limit_mb")
|
|
103
|
+
)
|
|
104
|
+
cpu = _string(resources, "cpu", where)
|
|
105
|
+
read: dict[str, object] = {
|
|
106
|
+
"cpu": cpu,
|
|
107
|
+
"memory_mb": _u64(resources, "memory_mb", where),
|
|
108
|
+
}
|
|
109
|
+
if per_domain and read["memory_mb"] == 0: # the manifest schema requires >= 1
|
|
110
|
+
raise FloeConfigError(f"{where}.memory_mb: must be >= 1")
|
|
111
|
+
memory_limit = _u64(resources, "memory_limit_mb", where)
|
|
112
|
+
if memory_limit == 0: # the schemas require >= 1
|
|
113
|
+
raise FloeConfigError(f"{where}.memory_limit_mb: must be >= 1")
|
|
114
|
+
limits = { # the limits are Python-only: carried only when set, so Rust manifests stay identical
|
|
115
|
+
"cpu_limit": _string(resources, "cpu_limit", where),
|
|
116
|
+
"memory_limit_mb": memory_limit,
|
|
117
|
+
}
|
|
118
|
+
return read | {k: v for k, v in limits.items() if v is not None}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _domain_resources(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
|
|
122
|
+
if key not in hash:
|
|
123
|
+
return None
|
|
124
|
+
where = f"{ctx}.{key}"
|
|
125
|
+
by_domain = yaml_hash(hash[key], where)
|
|
126
|
+
validate_known_keys(
|
|
127
|
+
by_domain, where, by_domain.keys()
|
|
128
|
+
) # every key must be a string
|
|
129
|
+
return {
|
|
130
|
+
str(domain): _resources(by_domain, str(domain), where, per_domain=True)
|
|
131
|
+
for domain in by_domain
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _job_scope(hash: Hash, key: str, ctx: str) -> str | None:
|
|
136
|
+
scope = _string(hash, key, ctx)
|
|
137
|
+
if scope not in (None, "entity", "domain"):
|
|
138
|
+
raise FloeConfigError(
|
|
139
|
+
f'{ctx}.{key}: expected "entity" or "domain", got "{scope}"'
|
|
140
|
+
)
|
|
141
|
+
return scope
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
|
|
145
|
+
if key not in hash:
|
|
146
|
+
return None
|
|
147
|
+
where = f"{ctx}.{key}"
|
|
148
|
+
auth = yaml_hash(hash[key], where)
|
|
149
|
+
validate_known_keys(auth, where, ("service_principal_oauth_ref",))
|
|
150
|
+
ref = _string(auth, "service_principal_oauth_ref", where)
|
|
151
|
+
return {"service_principal_oauth_ref": ref}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Rust reads the runner fields in this order, so the first malformed one is the one reported.
|
|
155
|
+
_RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
|
|
156
|
+
("command", _string),
|
|
157
|
+
("args", _strings),
|
|
158
|
+
("timeout_seconds", _u64),
|
|
159
|
+
("ttl_seconds_after_finished", _u64),
|
|
160
|
+
("poll_interval_seconds", _u64),
|
|
161
|
+
("secrets", _secrets),
|
|
162
|
+
("image", _string),
|
|
163
|
+
("namespace", _string),
|
|
164
|
+
("service_account", _string),
|
|
165
|
+
("resources", _resources),
|
|
166
|
+
("env", _map),
|
|
167
|
+
("workspace_url", _string),
|
|
168
|
+
("existing_cluster_id", _string),
|
|
169
|
+
("config_uri", _string),
|
|
170
|
+
("python_file_uri", _string),
|
|
171
|
+
("job_name", _string),
|
|
172
|
+
("auth", _auth),
|
|
173
|
+
("env_parameters", _map),
|
|
174
|
+
("job_scope", _job_scope),
|
|
175
|
+
("domain_resources", _domain_resources),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _runner(value: object) -> dict[str, object]:
|
|
180
|
+
ctx = "profile.execution.runner"
|
|
181
|
+
hash = yaml_hash(value, ctx)
|
|
182
|
+
validate_known_keys(hash, ctx, ("type", *(name for name, _ in _RUNNER_FIELDS)))
|
|
183
|
+
read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
|
|
184
|
+
for name, reader in _RUNNER_FIELDS:
|
|
185
|
+
read[name] = reader(hash, name, ctx)
|
|
186
|
+
return read
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _orchestration(value: object) -> dict[str, object]:
|
|
190
|
+
ctx = "profile.execution.orchestration"
|
|
191
|
+
hash = yaml_hash(value, ctx)
|
|
192
|
+
validate_known_keys(hash, ctx, ("max_concurrent_entities", "strategy"))
|
|
193
|
+
max_concurrent = _u64(hash, "max_concurrent_entities", ctx)
|
|
194
|
+
if max_concurrent == 0:
|
|
195
|
+
raise FloeConfigError(f"{ctx}.max_concurrent_entities: must be >= 1")
|
|
196
|
+
strategy = _string(hash, "strategy", ctx)
|
|
197
|
+
if strategy not in (None, "sequential", "parallel"):
|
|
198
|
+
raise FloeConfigError(
|
|
199
|
+
f'{ctx}.strategy: expected "sequential" or "parallel", got "{strategy}"'
|
|
200
|
+
)
|
|
201
|
+
read = {"max_concurrent_entities": max_concurrent, "strategy": strategy}
|
|
202
|
+
return {k: v for k, v in read.items() if v is not None}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _metadata(value: object) -> str:
|
|
206
|
+
ctx = "profile.metadata"
|
|
207
|
+
hash = yaml_hash(value, ctx)
|
|
208
|
+
validate_known_keys(hash, ctx, ("name", "description", "env", "tags"))
|
|
209
|
+
name = _string(hash, "name", ctx, True)
|
|
210
|
+
_string(hash, "description", ctx)
|
|
211
|
+
_string(hash, "env", ctx)
|
|
212
|
+
_strings(hash, "tags", ctx)
|
|
213
|
+
assert name is not None # `required` raised otherwise
|
|
214
|
+
return name
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _validation(value: object) -> None:
|
|
218
|
+
ctx = "profile.validation"
|
|
219
|
+
hash = yaml_hash(value, ctx)
|
|
220
|
+
validate_known_keys(hash, ctx, ("strict",))
|
|
221
|
+
if "strict" in hash and not isinstance(hash["strict"], bool):
|
|
222
|
+
raise FloeConfigError(f"{ctx}.strict must be a boolean")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_doc(doc: object) -> ProfileConfig:
|
|
226
|
+
root = yaml_hash(doc, "profile")
|
|
227
|
+
validate_known_keys(
|
|
228
|
+
root,
|
|
229
|
+
"profile",
|
|
230
|
+
(
|
|
231
|
+
*("apiVersion", "kind", "metadata", "execution", "variables"),
|
|
232
|
+
*("catalogs", "storages", "lineage", "validation"),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
for key, expected in (("apiVersion", API_VERSION), ("kind", KIND)):
|
|
236
|
+
got = _string(root, key, "profile", True)
|
|
237
|
+
if got != expected:
|
|
238
|
+
raise FloeConfigError(f'profile.{key}: expected "{expected}", got "{got}"')
|
|
239
|
+
if "metadata" not in root:
|
|
240
|
+
raise FloeConfigError("profile.metadata is required")
|
|
241
|
+
name = _metadata(root["metadata"])
|
|
242
|
+
runner: dict[str, object] = {}
|
|
243
|
+
orchestration = None
|
|
244
|
+
if "execution" in root:
|
|
245
|
+
ctx = "profile.execution"
|
|
246
|
+
execution = yaml_hash(root["execution"], ctx)
|
|
247
|
+
validate_known_keys(execution, ctx, ("runner", "orchestration"))
|
|
248
|
+
if "runner" not in execution:
|
|
249
|
+
raise FloeConfigError(f"{ctx}.runner is required")
|
|
250
|
+
runner = _runner(execution["runner"])
|
|
251
|
+
if "orchestration" in execution:
|
|
252
|
+
orchestration = _orchestration(execution["orchestration"])
|
|
253
|
+
variables = (
|
|
254
|
+
string_map(root["variables"], "profile.variables")
|
|
255
|
+
if "variables" in root
|
|
256
|
+
else {}
|
|
257
|
+
)
|
|
258
|
+
catalogs = (
|
|
259
|
+
parse_catalogs(root["catalogs"], "profile.catalogs")
|
|
260
|
+
if "catalogs" in root
|
|
261
|
+
else None
|
|
262
|
+
)
|
|
263
|
+
storages = parse_storages(root["storages"]) if "storages" in root else None
|
|
264
|
+
lineage = parse_lineage(root["lineage"]) if "lineage" in root else None
|
|
265
|
+
if "validation" in root:
|
|
266
|
+
_validation(root["validation"])
|
|
267
|
+
return ProfileConfig(
|
|
268
|
+
name, runner, variables, catalogs, storages, lineage, orchestration
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _malformed(value: str) -> str | None:
|
|
273
|
+
"""The first `${}` or unclosed `${` in `value`; well-formed references are left to `resolve_vars`."""
|
|
274
|
+
rest = value
|
|
275
|
+
while (start := rest.find("${")) != -1:
|
|
276
|
+
rest = rest[start + 2 :]
|
|
277
|
+
end = rest.find("}")
|
|
278
|
+
if end == -1:
|
|
279
|
+
return f"${{... (unclosed placeholder in: {rust_debug(value)})}}"[:120]
|
|
280
|
+
if not rest[:end].strip():
|
|
281
|
+
return "${} (empty placeholder)"
|
|
282
|
+
rest = rest[end + 1 :]
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _check_catalogs(catalogs: CatalogsConfig) -> None:
|
|
287
|
+
ctx = "profile.catalogs"
|
|
288
|
+
if not catalogs.definitions:
|
|
289
|
+
raise FloeConfigError(f"{ctx}.definitions must not be empty")
|
|
290
|
+
seen: set[str] = set()
|
|
291
|
+
for d in catalogs.definitions:
|
|
292
|
+
if not d.name.strip():
|
|
293
|
+
raise FloeConfigError(f"{ctx}.definitions.name must not be empty")
|
|
294
|
+
if d.name in seen:
|
|
295
|
+
raise FloeConfigError(f"{ctx}.definitions name={d.name} is duplicated")
|
|
296
|
+
seen.add(d.name)
|
|
297
|
+
if catalogs.default is not None and catalogs.default not in seen:
|
|
298
|
+
raise FloeConfigError(
|
|
299
|
+
f"{ctx}.default={catalogs.default} does not match any definition"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _check_storages(storages: StoragesConfig) -> None:
|
|
304
|
+
ctx = "profile.storages"
|
|
305
|
+
if not storages.definitions:
|
|
306
|
+
raise FloeConfigError(f"{ctx}.definitions must not be empty")
|
|
307
|
+
seen: set[str] = set()
|
|
308
|
+
for d in storages.definitions:
|
|
309
|
+
# ponytail: Rust's adls/gcs requirements are unreachable here; both types are rejected at parse (D1)
|
|
310
|
+
if not d.name.strip():
|
|
311
|
+
raise FloeConfigError(f"{ctx}.definitions.name must not be empty")
|
|
312
|
+
if d.name in seen:
|
|
313
|
+
raise FloeConfigError(f"{ctx}.definitions name={d.name} is duplicated")
|
|
314
|
+
seen.add(d.name)
|
|
315
|
+
if d.type not in ("local", "s3", "adls", "gcs"):
|
|
316
|
+
raise FloeConfigError(
|
|
317
|
+
f"{ctx}.definitions name={d.name} type={d.type} is unsupported "
|
|
318
|
+
"(allowed: local, s3, adls, gcs)"
|
|
319
|
+
)
|
|
320
|
+
for field in ("bucket", "region") if d.type == "s3" else ():
|
|
321
|
+
if getattr(d, field) is None:
|
|
322
|
+
raise FloeConfigError(
|
|
323
|
+
f"{ctx}.definitions name={d.name} requires {field} for type s3"
|
|
324
|
+
)
|
|
325
|
+
if storages.default is None:
|
|
326
|
+
raise FloeConfigError(f"{ctx}.default is required when storages is set")
|
|
327
|
+
if storages.default not in seen:
|
|
328
|
+
raise FloeConfigError(
|
|
329
|
+
f"{ctx}.default={storages.default} does not match any definition"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def validate_profile(profile: ProfileConfig) -> None:
|
|
334
|
+
ctx = "profile.execution.runner"
|
|
335
|
+
if not profile.name.strip():
|
|
336
|
+
raise FloeConfigError("profile.metadata.name must not be empty")
|
|
337
|
+
if profile.runner:
|
|
338
|
+
kind = profile.runner["type"]
|
|
339
|
+
if kind not in RUNNERS:
|
|
340
|
+
raise FloeConfigError(
|
|
341
|
+
f'{ctx}.type: unknown runner "{kind}"; known runners: {", ".join(RUNNERS)}'
|
|
342
|
+
)
|
|
343
|
+
for field in CONTRACT.get(kind, ()):
|
|
344
|
+
key, _, sub = field.partition(".")
|
|
345
|
+
value = profile.runner.get(key)
|
|
346
|
+
if sub and isinstance(value, dict):
|
|
347
|
+
value = cast(dict[str, object], value).get(sub)
|
|
348
|
+
if not isinstance(value, str) or not value.strip():
|
|
349
|
+
raise FloeConfigError(f"{ctx}.{field} is required for {kind}")
|
|
350
|
+
for key, value in profile.variables.items():
|
|
351
|
+
if (placeholder := _malformed(value)) is not None:
|
|
352
|
+
raise FloeConfigError(
|
|
353
|
+
f'profile variable "{key}" contains malformed placeholder: {placeholder}'
|
|
354
|
+
)
|
|
355
|
+
if profile.catalogs is not None:
|
|
356
|
+
_check_catalogs(profile.catalogs)
|
|
357
|
+
if profile.storages is not None:
|
|
358
|
+
_check_storages(profile.storages)
|
|
359
|
+
if profile.lineage is not None:
|
|
360
|
+
check_lineage(profile.lineage, "profile.lineage")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
|
|
364
|
+
"""The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
|
|
365
|
+
blocks = {
|
|
366
|
+
"catalogs": profile.catalogs,
|
|
367
|
+
"storages": profile.storages,
|
|
368
|
+
"lineage": profile.lineage,
|
|
369
|
+
}
|
|
370
|
+
return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def parse_profile(path: Path) -> ProfileConfig:
|
|
374
|
+
docs = load_yaml(path)
|
|
375
|
+
if not docs:
|
|
376
|
+
raise FloeConfigError("profile YAML is empty")
|
|
377
|
+
if len(docs) > 1:
|
|
378
|
+
raise FloeConfigError("profile YAML contains multiple documents; expected one")
|
|
379
|
+
profile = _parse_doc(docs[0])
|
|
380
|
+
validate_profile(profile)
|
|
381
|
+
return profile
|