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.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. 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
floe/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ class FloeError(Exception): ...
2
+
3
+
4
+ class FloeConfigError(FloeError): ...
5
+
6
+
7
+ class FloeRunError(FloeError): ...
8
+
9
+
10
+ class FloeStorageError(FloeError): ...
11
+
12
+
13
+ class FloeIoError(FloeError): ...