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/vars.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Variable resolution, ported from `config/template.rs`, `config/parse.rs` (`extract_raw_env_vars`) and `vars/resolve.rs`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from floe.config.yaml import load_yaml, rust_debug, yaml_hash, yaml_string
|
|
8
|
+
from floe.errors import FloeConfigError, FloeError
|
|
9
|
+
from floe.storage.paths import normalize_local_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_local_path(config_dir: Path, raw: str) -> Path:
|
|
13
|
+
return normalize_local_path(config_dir / raw)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def string_map(value: object, ctx: str) -> dict[str, str]:
|
|
17
|
+
return {
|
|
18
|
+
yaml_string(k, ctx): yaml_string(v, ctx)
|
|
19
|
+
for k, v in yaml_hash(value, ctx).items()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def replace_placeholders(
|
|
24
|
+
value: str, variables: Mapping[str, str], field: str, entity: str | None = None
|
|
25
|
+
) -> str:
|
|
26
|
+
prefix = f"entity.name={entity} " if entity is not None else ""
|
|
27
|
+
result: list[str] = []
|
|
28
|
+
rest = value
|
|
29
|
+
while (start := rest.find("{{")) != -1:
|
|
30
|
+
result.append(rest[:start])
|
|
31
|
+
rest = rest[start + 2 :]
|
|
32
|
+
end = rest.find("}}")
|
|
33
|
+
if end == -1:
|
|
34
|
+
raise FloeConfigError(f"{prefix}{field} missing closing '}}'")
|
|
35
|
+
key = rest[:end].strip()
|
|
36
|
+
if not key:
|
|
37
|
+
raise FloeConfigError(f"{prefix}{field} empty placeholder")
|
|
38
|
+
if key not in variables:
|
|
39
|
+
raise FloeConfigError(f"{prefix}{field} references unknown variable {key}")
|
|
40
|
+
result.append(variables[key])
|
|
41
|
+
rest = rest[end + 2 :]
|
|
42
|
+
return "".join(result) + rest
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_env_vars(
|
|
46
|
+
config_dir: Path,
|
|
47
|
+
env_file: str | None,
|
|
48
|
+
env_vars: Mapping[str, str],
|
|
49
|
+
profile_vars: Mapping[str, str],
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
merged = dict(profile_vars)
|
|
52
|
+
if env_file is not None:
|
|
53
|
+
path = resolve_local_path(config_dir, env_file)
|
|
54
|
+
docs = load_yaml(path)
|
|
55
|
+
if not docs:
|
|
56
|
+
raise FloeConfigError(f"env file {path} is empty")
|
|
57
|
+
merged |= string_map(docs[0], "env.file")
|
|
58
|
+
return merged | dict(env_vars)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_config_env_vars(path: Path) -> dict[str, str]:
|
|
62
|
+
variables: dict[str, str] = {}
|
|
63
|
+
with suppress(FloeError):
|
|
64
|
+
docs = load_yaml(path)
|
|
65
|
+
root = yaml_hash(docs[0], "root") if docs else {}
|
|
66
|
+
env = yaml_hash(root.get("env"), "env")
|
|
67
|
+
with suppress(FloeError, IndexError):
|
|
68
|
+
file = resolve_local_path(
|
|
69
|
+
path.parent, yaml_string(env.get("file"), "env.file")
|
|
70
|
+
)
|
|
71
|
+
for k, v in yaml_hash(load_yaml(file)[0], "env.file").items():
|
|
72
|
+
if isinstance(k, str) and isinstance(v, str):
|
|
73
|
+
variables[k] = v
|
|
74
|
+
with suppress(FloeError, KeyError):
|
|
75
|
+
variables |= string_map(env["vars"], "env.vars")
|
|
76
|
+
return variables
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_vars(
|
|
80
|
+
profile: Mapping[str, str], cli: Mapping[str, str], config: Mapping[str, str]
|
|
81
|
+
) -> dict[str, str]:
|
|
82
|
+
raw = dict(profile) | dict(cli) | dict(config)
|
|
83
|
+
resolved: dict[str, str] = {}
|
|
84
|
+
in_progress: list[str] = []
|
|
85
|
+
|
|
86
|
+
def expand_key(key: str, depth: int) -> str:
|
|
87
|
+
if key in resolved:
|
|
88
|
+
return resolved[key]
|
|
89
|
+
if depth > 32:
|
|
90
|
+
raise FloeConfigError(
|
|
91
|
+
f"variable expansion exceeded maximum depth (32); "
|
|
92
|
+
f'check for deeply nested or circular references near "${{{key}}}"'
|
|
93
|
+
)
|
|
94
|
+
if key in in_progress:
|
|
95
|
+
chain = " -> ".join([*in_progress, key])
|
|
96
|
+
raise FloeConfigError(f"circular variable reference detected: {chain}")
|
|
97
|
+
if key not in raw:
|
|
98
|
+
raise FloeConfigError(
|
|
99
|
+
f'variable "${{{key}}}" is referenced but not defined'
|
|
100
|
+
)
|
|
101
|
+
in_progress.append(key)
|
|
102
|
+
resolved[key] = expand_value(key, depth + 1)
|
|
103
|
+
in_progress.pop()
|
|
104
|
+
return resolved[key]
|
|
105
|
+
|
|
106
|
+
def expand_value(owner: str, depth: int) -> str:
|
|
107
|
+
value = rest = raw[owner]
|
|
108
|
+
result: list[str] = []
|
|
109
|
+
while (start := rest.find("${")) != -1:
|
|
110
|
+
result.append(rest[:start])
|
|
111
|
+
rest = rest[start + 2 :]
|
|
112
|
+
end = rest.find("}")
|
|
113
|
+
if end == -1:
|
|
114
|
+
quoted = rust_debug(value)
|
|
115
|
+
raise FloeConfigError(
|
|
116
|
+
f'variable "{owner}": unclosed placeholder in value {quoted}'
|
|
117
|
+
)
|
|
118
|
+
ref = rest[:end].strip()
|
|
119
|
+
if not ref:
|
|
120
|
+
raise FloeConfigError(f'variable "{owner}": empty placeholder ${{}}')
|
|
121
|
+
result.append(expand_key(ref, depth))
|
|
122
|
+
rest = rest[end + 1 :]
|
|
123
|
+
return "".join(result) + rest
|
|
124
|
+
|
|
125
|
+
for key in raw:
|
|
126
|
+
expand_key(key, 0)
|
|
127
|
+
return resolved
|
floe/config/yaml.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Strict YAML decoding, ported from `crates/floe-core/src/config/yaml_decode.rs` (yaml-rust2 0.11)."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from collections.abc import Container, Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from yaml.error import MarkedYAMLError
|
|
12
|
+
from yaml.nodes import CollectionNode, Node, ScalarNode, SequenceNode
|
|
13
|
+
|
|
14
|
+
from floe.errors import FloeConfigError, FloeError, FloeIoError
|
|
15
|
+
|
|
16
|
+
_DIGITS = {10: r"[+-]?[0-9]+", 8: r"[+-]?[0-7]+", 16: r"[+-]?[0-9a-fA-F]+"}
|
|
17
|
+
_FLOAT = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")
|
|
18
|
+
_KEYWORDS = {"": None, "~": None, "null": None} | {
|
|
19
|
+
w: w[0] in "tT" for w in ("true", "True", "TRUE", "false", "False", "FALSE")
|
|
20
|
+
}
|
|
21
|
+
_INF = re.compile(r"[+-]?\.(?:inf|Inf|INF)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _i64(digits: str, base: int) -> int | None:
|
|
25
|
+
if not re.fullmatch(_DIGITS[base], digits):
|
|
26
|
+
return None
|
|
27
|
+
magnitude = digits.lstrip("+-").lstrip("0")
|
|
28
|
+
if len(magnitude) > 25: # past i64 in any base; keeps int() under its digit limit
|
|
29
|
+
return None
|
|
30
|
+
n = int(magnitude or "0", base) * (-1 if digits[0] == "-" else 1)
|
|
31
|
+
return n if -(2**63) <= n < 2**63 else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _plain(v: str) -> object:
|
|
35
|
+
"""`Yaml::from_str`: the only place a plain scalar becomes a non-string."""
|
|
36
|
+
for prefix, base in (("0x", 16), ("0o", 8), ("+", 10)):
|
|
37
|
+
if v.startswith(prefix) and (n := _i64(v[len(prefix) :], base)) is not None:
|
|
38
|
+
return n
|
|
39
|
+
if v in _KEYWORDS:
|
|
40
|
+
return _KEYWORDS[v]
|
|
41
|
+
if (n := _i64(v, 10)) is not None:
|
|
42
|
+
return n
|
|
43
|
+
if _INF.fullmatch(v):
|
|
44
|
+
return float(v.replace(".", ""))
|
|
45
|
+
if v in (".nan", ".NaN", ".NAN"):
|
|
46
|
+
return float("nan")
|
|
47
|
+
return float(v) if _FLOAT.fullmatch(v) else v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_MARKS = ("Mn", "Me")
|
|
51
|
+
_FILLERS = "\u115f\u1160\u3164\uffa0"
|
|
52
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\t": "\\t", "\n": "\\n", "\r": "\\r", "\0": "\\0"} # fmt: skip
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _debug_char(c: str) -> str:
|
|
56
|
+
# ponytail: Rust also hides other grapheme-extend/default-ignorable characters; this covers Mn, Me and the Hangul fillers
|
|
57
|
+
hidden = not c.isprintable() or unicodedata.category(c) in _MARKS or c in _FILLERS
|
|
58
|
+
return _ESCAPES.get(c) or (f"\\u{{{ord(c):x}}}" if hidden else c)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rust_debug(s: str) -> str:
|
|
62
|
+
"""Rust's `{:?}` of a `str`."""
|
|
63
|
+
return '"' + "".join(map(_debug_char, s)) + '"'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _where(index: int, line: int, column: int) -> str:
|
|
67
|
+
# yaml-rust2 says "byte" but counts characters
|
|
68
|
+
return f"at byte {index} line {line + 1} column {column + 1}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _debug(key: object, node: Node) -> str:
|
|
72
|
+
if key is None:
|
|
73
|
+
return "Null"
|
|
74
|
+
if isinstance(key, bool):
|
|
75
|
+
return f"Boolean({str(key).lower()})"
|
|
76
|
+
if isinstance(key, int):
|
|
77
|
+
return f"Integer({key})"
|
|
78
|
+
if isinstance(key, float):
|
|
79
|
+
return f"Real({rust_debug(cast(str, node.value))})"
|
|
80
|
+
return f"String({rust_debug(cast(str, key))})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build(node: Node, active: tuple[int, ...] = ()) -> object:
|
|
84
|
+
if id(node) in active:
|
|
85
|
+
return None # recursive alias (`&a [*a]`): yaml-rust2 loads BadValue, which no reader accepts either
|
|
86
|
+
active = (*active, id(node))
|
|
87
|
+
if isinstance(node, ScalarNode):
|
|
88
|
+
value = cast(str, node.value)
|
|
89
|
+
# ponytail: an explicit tag (`!!str 5`) is ignored; yaml-rust2 honours it. Add when a config needs it.
|
|
90
|
+
return _plain(value) if node.style is None else value
|
|
91
|
+
if isinstance(node, SequenceNode):
|
|
92
|
+
return [_build(item, active) for item in cast("list[Node]", node.value)]
|
|
93
|
+
result: dict[object, object] = {}
|
|
94
|
+
seen: set[tuple[type, object]] = set()
|
|
95
|
+
for key_node, value_node in cast("list[tuple[Node, Node]]", node.value):
|
|
96
|
+
key = _build(key_node, active)
|
|
97
|
+
if isinstance(key, (list, dict)):
|
|
98
|
+
raise FloeError("unsupported complex mapping key")
|
|
99
|
+
value = _build(value_node, active)
|
|
100
|
+
# yaml-rust2 keeps `true`, `1`, `1.0`, `1.00` apart. ponytail: `result` still merges `true` and `1`
|
|
101
|
+
identity = (type(key), key_node.value if isinstance(key, float) else key)
|
|
102
|
+
if identity in seen:
|
|
103
|
+
# position of the value's last token; ponytail: wrong for anchored/tagged values and block scalars
|
|
104
|
+
collection = isinstance(value_node, CollectionNode)
|
|
105
|
+
mark = value_node.end_mark if collection else value_node.start_mark
|
|
106
|
+
back = int(collection and bool(value_node.flow_style))
|
|
107
|
+
where = _where(mark.index - back, mark.line, mark.column - back)
|
|
108
|
+
raise FloeError(
|
|
109
|
+
f"{_debug(key, key_node)}: duplicated key in mapping {where}"
|
|
110
|
+
)
|
|
111
|
+
seen.add(identity)
|
|
112
|
+
result[key] = value
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_yaml(path: Path) -> list[object]:
|
|
117
|
+
try:
|
|
118
|
+
text = path.read_bytes().decode()
|
|
119
|
+
except OSError as e:
|
|
120
|
+
raise FloeIoError(f"{e.strerror} (os error {e.errno})") from e
|
|
121
|
+
except UnicodeDecodeError as e:
|
|
122
|
+
raise FloeIoError("stream did not contain valid UTF-8") from e
|
|
123
|
+
except ValueError as e: # an embedded NUL in the path
|
|
124
|
+
raise FloeIoError("file name contained an unexpected NUL byte") from e
|
|
125
|
+
return parse_yaml(text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_yaml(text: str) -> list[object]:
|
|
129
|
+
try:
|
|
130
|
+
# Composing keeps `<<` an ordinary key and aliases plain copies; no PyYAML constructor runs.
|
|
131
|
+
docs = cast(
|
|
132
|
+
"Iterator[Node]",
|
|
133
|
+
yaml.compose_all(text, Loader=yaml.SafeLoader), # pyright: ignore[reportUnknownMemberType] -- the stub leaves the stream type unknown
|
|
134
|
+
)
|
|
135
|
+
return [_build(doc) for doc in docs]
|
|
136
|
+
except MarkedYAMLError as e:
|
|
137
|
+
# ponytail: only some scanner messages match yaml-rust2 word for word; the rest keep PyYAML's wording
|
|
138
|
+
context = f"{e.context}, " if e.context else ""
|
|
139
|
+
problem = (e.problem or "").split(", but got")[0]
|
|
140
|
+
mark = e.problem_mark
|
|
141
|
+
where = _where(mark.index, mark.line, mark.column) if mark else ""
|
|
142
|
+
raise FloeError(f"{context}{problem} {where}".strip()) from e
|
|
143
|
+
except yaml.YAMLError as e:
|
|
144
|
+
raise FloeError(str(e)) from e
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_single_doc(path: Path) -> object:
|
|
148
|
+
docs = load_yaml(path)
|
|
149
|
+
if not docs:
|
|
150
|
+
raise FloeConfigError("YAML is empty")
|
|
151
|
+
if len(docs) > 1:
|
|
152
|
+
raise FloeConfigError("YAML contains multiple documents; expected one")
|
|
153
|
+
return docs[0]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def yaml_hash(value: object, ctx: str) -> dict[object, object]:
|
|
157
|
+
if not isinstance(value, dict):
|
|
158
|
+
raise FloeConfigError(f"expected map at {ctx}")
|
|
159
|
+
return cast("dict[object, object]", value)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def yaml_string(value: object, ctx: str) -> str:
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
raise FloeConfigError(f"expected string at {ctx}")
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def yaml_array(value: object, ctx: str) -> list[object]:
|
|
169
|
+
if not isinstance(value, list):
|
|
170
|
+
raise FloeConfigError(f"expected array at {ctx}")
|
|
171
|
+
return cast("list[object]", value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_known_keys(
|
|
175
|
+
hash: dict[object, object], ctx: str, allowed: Container[str]
|
|
176
|
+
) -> None:
|
|
177
|
+
for key in hash:
|
|
178
|
+
if not isinstance(key, str):
|
|
179
|
+
raise FloeConfigError(f"expected string key at {ctx}")
|
|
180
|
+
if key not in allowed:
|
|
181
|
+
raise FloeConfigError(f"unknown field {ctx}.{key}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Ports of the field readers in `config/parse.rs`; `None` is an absent or null value.
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_value(hash: dict[object, object], key: str, ctx: str) -> object:
|
|
188
|
+
if key not in hash:
|
|
189
|
+
raise FloeConfigError(f"missing required field {ctx}.{key}")
|
|
190
|
+
return hash[key]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_string(hash: dict[object, object], key: str, ctx: str) -> str:
|
|
194
|
+
return yaml_string(get_value(hash, key, ctx), f"{ctx}.{key}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_array(hash: dict[object, object], key: str, ctx: str) -> list[object]:
|
|
198
|
+
return yaml_array(get_value(hash, key, ctx), f"{ctx}.{key}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def opt_string(hash: dict[object, object], key: str, ctx: str) -> str | None:
|
|
202
|
+
value = hash.get(key)
|
|
203
|
+
return None if value is None else yaml_string(value, f"{ctx}.{key}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _strings(value: object, ctx: str) -> list[str]:
|
|
207
|
+
items = yaml_array(value, ctx)
|
|
208
|
+
return [yaml_string(item, f"{ctx}[{i}]") for i, item in enumerate(items)]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def opt_vec_string(hash: dict[object, object], key: str, ctx: str) -> list[str] | None:
|
|
212
|
+
value = hash.get(key)
|
|
213
|
+
return None if value is None else _strings(value, f"{ctx}.{key}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def opt_vec_vec_string(
|
|
217
|
+
hash: dict[object, object], key: str, ctx: str
|
|
218
|
+
) -> list[list[str]] | None:
|
|
219
|
+
value = hash.get(key)
|
|
220
|
+
if value is None:
|
|
221
|
+
return None
|
|
222
|
+
base = f"{ctx}.{key}"
|
|
223
|
+
rows = yaml_array(value, base)
|
|
224
|
+
return [_strings(row, f"{base}[{i}]") for i, row in enumerate(rows)]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def opt_bool(hash: dict[object, object], key: str, ctx: str) -> bool | None:
|
|
228
|
+
value = hash.get(key)
|
|
229
|
+
if value is not None and not isinstance(value, bool):
|
|
230
|
+
raise FloeConfigError(f"expected boolean at {ctx}.{key}")
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def opt_u64(hash: dict[object, object], key: str, ctx: str) -> int | None:
|
|
235
|
+
value = hash.get(key)
|
|
236
|
+
if value is None:
|
|
237
|
+
return None
|
|
238
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
239
|
+
raise FloeConfigError(f"expected integer at {ctx}.{key}")
|
|
240
|
+
if value < 0:
|
|
241
|
+
raise FloeConfigError(f"expected positive integer at {ctx}.{key}")
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def opt_u32(hash: dict[object, object], key: str, ctx: str) -> int | None:
|
|
246
|
+
value = opt_u64(hash, key, ctx)
|
|
247
|
+
if value is not None and value > 0xFFFFFFFF:
|
|
248
|
+
raise FloeConfigError(f"value at {ctx}.{key} exceeds maximum allowed value")
|
|
249
|
+
return value
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def merge_yaml(base: object, overlay: object) -> object:
|
|
253
|
+
"""`merge_yaml`: maps merge key by key, anything else is replaced. Inputs are not modified."""
|
|
254
|
+
if not (isinstance(base, dict) and isinstance(overlay, dict)):
|
|
255
|
+
return copy.deepcopy(overlay)
|
|
256
|
+
base = cast("dict[object, object]", base)
|
|
257
|
+
merged = copy.deepcopy(base)
|
|
258
|
+
for key, value in cast("dict[object, object]", overlay).items():
|
|
259
|
+
merged.pop(key, None) # a re-inserted key moves to the back, as in hashlink
|
|
260
|
+
merged[key] = (
|
|
261
|
+
merge_yaml(base[key], value) if key in base else copy.deepcopy(value)
|
|
262
|
+
)
|
|
263
|
+
return merged
|