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/add_entity.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""`floe add-entity`, ported from `crates/floe-core/src/add_entity.rs`: infer an entity from a sample file."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import NamedTuple, cast
|
|
15
|
+
|
|
16
|
+
import polars as pl
|
|
17
|
+
|
|
18
|
+
from floe import __version__
|
|
19
|
+
from floe.config.include import glob_base
|
|
20
|
+
from floe.config.parse import parse_config
|
|
21
|
+
from floe.config.project import V0_3, config_version, validate_domains
|
|
22
|
+
from floe.config.types import SourceOptions
|
|
23
|
+
from floe.config.validate import validate_entities
|
|
24
|
+
from floe.config.yaml import _FLOAT, load_yaml, parse_yaml, rust_debug # pyright: ignore[reportPrivateUsage] -- the YAML float grammar the emitter must quote
|
|
25
|
+
from floe.errors import FloeConfigError, FloeError, FloeIoError
|
|
26
|
+
from floe.glob import MatchOptions, Pattern, PatternError, glob
|
|
27
|
+
|
|
28
|
+
SAMPLE_ROWS = 200
|
|
29
|
+
Hash = dict[object, object]
|
|
30
|
+
_COMPONENT_WISE = MatchOptions(
|
|
31
|
+
require_literal_separator=True
|
|
32
|
+
) # as the glob walker: `*` stays in one component
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _os(e: OSError) -> str:
|
|
36
|
+
return f"{e.strerror} (os error {e.errno})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _file_uri_path(input: str) -> str:
|
|
40
|
+
url = urllib.parse.urlparse(input)
|
|
41
|
+
if url.netloc not in ("", "localhost"):
|
|
42
|
+
raise FloeConfigError(f"invalid file:// URI for add-entity input: {input}")
|
|
43
|
+
return urllib.request.url2pathname(url.path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _entity_name(input: str) -> str:
|
|
47
|
+
scheme, sep, _ = input.partition("://")
|
|
48
|
+
if sep and scheme.lower() != "file":
|
|
49
|
+
raw = input.rstrip("/").rsplit("/", 1)[-1].split("?")[0]
|
|
50
|
+
else:
|
|
51
|
+
path = Path(_file_uri_path(input) if sep else input)
|
|
52
|
+
raw = path.stem if path.name not in ("", "..") else "entity"
|
|
53
|
+
name = re.sub("[^A-Za-z0-9]+", "_", raw).strip("_").lower()
|
|
54
|
+
if not name:
|
|
55
|
+
raise FloeConfigError(f"failed to derive entity name from input: {input}")
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _local_path(input: str) -> str:
|
|
60
|
+
scheme, sep, _ = input.partition("://")
|
|
61
|
+
if sep:
|
|
62
|
+
if scheme.lower() != "file":
|
|
63
|
+
raise FloeConfigError(
|
|
64
|
+
f"remote URI inference is not supported yet for add-entity input: {input}"
|
|
65
|
+
)
|
|
66
|
+
return _file_uri_path(input)
|
|
67
|
+
path = os.path.join(os.getcwd(), input)
|
|
68
|
+
try:
|
|
69
|
+
mode = os.stat(path).st_mode
|
|
70
|
+
except OSError as e:
|
|
71
|
+
raise FloeIoError(f"failed to access input at {path}: {_os(e)}") from e
|
|
72
|
+
if stat.S_ISDIR(mode):
|
|
73
|
+
raise FloeConfigError(
|
|
74
|
+
f"add-entity expects a file input for schema inference, got directory: {path}"
|
|
75
|
+
)
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sniff_separator(path: str) -> str:
|
|
80
|
+
"""Rust `sniff_delimiter`: the candidate most consistent over the first non-blank lines."""
|
|
81
|
+
lines: list[str] = []
|
|
82
|
+
try:
|
|
83
|
+
with open(path, "rb") as f:
|
|
84
|
+
for _, raw in zip(range(12), f, strict=False):
|
|
85
|
+
if line := raw.decode().strip():
|
|
86
|
+
lines.append(line)
|
|
87
|
+
if len(lines) >= 6:
|
|
88
|
+
break
|
|
89
|
+
except (OSError, UnicodeDecodeError):
|
|
90
|
+
return ","
|
|
91
|
+
best: tuple[int, str] | None = None
|
|
92
|
+
for candidate in ",;\t|":
|
|
93
|
+
counts = [line.count(candidate) for line in lines]
|
|
94
|
+
if not counts or counts[0] == 0:
|
|
95
|
+
continue
|
|
96
|
+
bonus = 10_000 if min(counts) > 0 and max(counts) == min(counts) else 0
|
|
97
|
+
score = bonus + min(counts) * 100 + sum(counts)
|
|
98
|
+
if best is None or score > best[0]:
|
|
99
|
+
best = (score, candidate)
|
|
100
|
+
return best[1] if best else ","
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _floe_type(dtype: pl.DataType) -> str:
|
|
104
|
+
# D27b (#619): a `time` column becomes `string`; Rust writes `time`, which 0.8 rejects (D10)
|
|
105
|
+
for kind, name in ((pl.Boolean, "boolean"), (pl.Date, "date"), (pl.Datetime, "datetime")): # fmt: skip
|
|
106
|
+
if dtype == kind:
|
|
107
|
+
return name
|
|
108
|
+
return "number" if dtype.is_numeric() else "string"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _json_sample(path: str) -> tuple[str, list[dict[str, object]]]:
|
|
112
|
+
"""Rust's JSON sampling: the `json_mode` and the first 200 top-level objects."""
|
|
113
|
+
try:
|
|
114
|
+
text = Path(path).read_bytes().decode()
|
|
115
|
+
except OSError as e:
|
|
116
|
+
raise FloeIoError(f"failed to read json at {path}: {_os(e)}") from e
|
|
117
|
+
except UnicodeDecodeError as e:
|
|
118
|
+
raise FloeIoError(f"failed to read json at {path}: stream did not contain valid UTF-8") from e # fmt: skip
|
|
119
|
+
if not (head := text.lstrip()[:1]):
|
|
120
|
+
raise FloeConfigError(f"json input {path} is empty")
|
|
121
|
+
if head == "[":
|
|
122
|
+
try:
|
|
123
|
+
rows = json.loads(text)
|
|
124
|
+
except json.JSONDecodeError as e:
|
|
125
|
+
raise FloeIoError(f"json parse error: {e}") from e
|
|
126
|
+
if not isinstance(rows, list):
|
|
127
|
+
raise FloeConfigError("expected json array at root")
|
|
128
|
+
rows = cast("list[object]", rows)[:SAMPLE_ROWS]
|
|
129
|
+
if not all(isinstance(row, dict) for row in rows):
|
|
130
|
+
raise FloeConfigError("expected top-level json objects inside array")
|
|
131
|
+
return "array", cast("list[dict[str, object]]", rows)
|
|
132
|
+
objects: list[dict[str, object]] = []
|
|
133
|
+
for n, line in enumerate(text.split("\n"), 1):
|
|
134
|
+
if len(objects) >= SAMPLE_ROWS:
|
|
135
|
+
break
|
|
136
|
+
if not (line := line.strip()):
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
row = json.loads(line)
|
|
140
|
+
except json.JSONDecodeError as e:
|
|
141
|
+
raise FloeIoError(f"json parse error at line {n}: {e}") from e
|
|
142
|
+
if not isinstance(row, dict):
|
|
143
|
+
raise FloeConfigError(f"expected top-level json object at line {n}")
|
|
144
|
+
objects.append(cast("dict[str, object]", row))
|
|
145
|
+
return "ndjson", objects
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read_json(rows: list[dict[str, object]]) -> pl.DataFrame:
|
|
149
|
+
"""Polars' schema for the sampled objects; a key Polars can't type (mixed kinds) is read as text."""
|
|
150
|
+
text = "\n".join(map(json.dumps, rows))
|
|
151
|
+
|
|
152
|
+
def read(strings: list[str]) -> pl.DataFrame:
|
|
153
|
+
overrides = dict.fromkeys(strings, pl.String())
|
|
154
|
+
return pl.read_ndjson(io.StringIO(text), infer_schema_length=None, schema_overrides=overrides) # fmt: skip
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
return read([])
|
|
158
|
+
except pl.exceptions.PolarsError:
|
|
159
|
+
keys = list(dict.fromkeys(k for row in rows for k in row))
|
|
160
|
+
mixed: list[str] = []
|
|
161
|
+
for key in keys:
|
|
162
|
+
try:
|
|
163
|
+
read([k for k in keys if k != key])
|
|
164
|
+
except pl.exceptions.PolarsError:
|
|
165
|
+
mixed.append(key)
|
|
166
|
+
return read(mixed)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _temporal(column: pl.Series) -> pl.DataType:
|
|
170
|
+
"""`Date` or `Datetime` when Polars parses every value of a text column, else `String`."""
|
|
171
|
+
values = column.drop_nulls()
|
|
172
|
+
for parse in (values.str.to_date, values.str.to_datetime):
|
|
173
|
+
try:
|
|
174
|
+
parsed = parse(strict=False)
|
|
175
|
+
except pl.exceptions.PolarsError: # no format fits the first value
|
|
176
|
+
continue
|
|
177
|
+
if len(values) and parsed.null_count() == 0:
|
|
178
|
+
return parsed.dtype
|
|
179
|
+
return pl.String()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _columns(fmt: str, path: str) -> tuple[Hash | None, list[Hash], list[str]]:
|
|
183
|
+
"""(source options, schema columns, notes), from Polars' own schema inference."""
|
|
184
|
+
notes: list[str] = []
|
|
185
|
+
if fmt == "csv":
|
|
186
|
+
separator = _sniff_separator(path)
|
|
187
|
+
try:
|
|
188
|
+
df = pl.read_csv(
|
|
189
|
+
path, separator=separator, n_rows=SAMPLE_ROWS, infer_schema_length=SAMPLE_ROWS,
|
|
190
|
+
null_values=SourceOptions.model_fields["null_values"].default, glob=False,
|
|
191
|
+
) # fmt: skip
|
|
192
|
+
except (OSError, pl.exceptions.PolarsError) as e:
|
|
193
|
+
raise FloeIoError(f"csv schema inference failed: {e}") from e
|
|
194
|
+
nullable = {c.name: df.height == 0 or c.null_count() > 0 for c in df.iter_columns()} # fmt: skip
|
|
195
|
+
schema, options = df.schema, cast(Hash, {"separator": separator})
|
|
196
|
+
elif fmt == "parquet":
|
|
197
|
+
try: # a file object: Polars would expand glob characters in a path
|
|
198
|
+
with open(path, "rb") as f:
|
|
199
|
+
schema = pl.read_parquet_schema(f)
|
|
200
|
+
except OSError as e:
|
|
201
|
+
raise FloeIoError(f"failed to open parquet at {path}: {_os(e)}") from e
|
|
202
|
+
except pl.exceptions.PolarsError as e:
|
|
203
|
+
raise FloeIoError(f"failed to read parquet schema at {path}: {e}") from e
|
|
204
|
+
nullable, options = dict.fromkeys(schema, True), None
|
|
205
|
+
elif fmt == "json": # D27a (#619): Polars types the values; Rust walks them by hand
|
|
206
|
+
mode, rows = _json_sample(path)
|
|
207
|
+
if not rows:
|
|
208
|
+
raise FloeConfigError(f"no json objects found in {path}")
|
|
209
|
+
df = _read_json(rows)
|
|
210
|
+
nullable = {c.name: c.null_count() > 0 for c in df.iter_columns()}
|
|
211
|
+
schema = {n: _temporal(df[n]) if t == pl.String else t for n, t in sorted(df.schema.items())} # fmt: skip
|
|
212
|
+
options = cast(Hash, {"json_mode": mode})
|
|
213
|
+
notes.append(f"json inference sampled {len(rows)} row(s) in {mode} mode")
|
|
214
|
+
if nested := sorted({k for row in rows for k, v in row.items() if isinstance(v, (list, dict))}): # fmt: skip
|
|
215
|
+
notes.append(f"nested JSON values inferred as string for top-level key(s): {', '.join(nested)}") # fmt: skip
|
|
216
|
+
else:
|
|
217
|
+
raise FloeConfigError(f"unsupported add-entity format: {fmt} (allowed: csv, json, parquet)") # fmt: skip
|
|
218
|
+
return (
|
|
219
|
+
options,
|
|
220
|
+
[
|
|
221
|
+
{
|
|
222
|
+
"name": n,
|
|
223
|
+
"source": n,
|
|
224
|
+
"type": _floe_type(t),
|
|
225
|
+
"nullable": nullable[n],
|
|
226
|
+
"unique": False,
|
|
227
|
+
}
|
|
228
|
+
for n, t in schema.items()
|
|
229
|
+
],
|
|
230
|
+
notes,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def infer_entity(
|
|
235
|
+
input: str, fmt: str | None, name: str | None, domain: str | None,
|
|
236
|
+
source_ref: str | None, resource: str | None,
|
|
237
|
+
) -> tuple[Hash, str, list[str]]: # fmt: skip
|
|
238
|
+
"""The entity generated for a sample file, its format and notes (Rust `infer_entity_from_input`)."""
|
|
239
|
+
name = _entity_name(input) if name is None else name
|
|
240
|
+
if not name.strip():
|
|
241
|
+
raise FloeConfigError("entity name must not be empty")
|
|
242
|
+
path = _local_path(input)
|
|
243
|
+
if fmt is None:
|
|
244
|
+
# `.csv` alone has no extension, as in Rust
|
|
245
|
+
fmt = os.path.splitext(path)[1][1:].lower()
|
|
246
|
+
if fmt not in ("csv", "json", "parquet"):
|
|
247
|
+
raise FloeConfigError(
|
|
248
|
+
f"could not infer add-entity format from input {input} "
|
|
249
|
+
"(supported extensions: .csv, .json, .parquet); use --format"
|
|
250
|
+
)
|
|
251
|
+
options, columns, notes = _columns(fmt, path)
|
|
252
|
+
if not columns:
|
|
253
|
+
raise FloeConfigError(f"no columns inferred from {path}")
|
|
254
|
+
if source_ref is not None:
|
|
255
|
+
source: Hash = {"ref": source_ref, "resource": resource}
|
|
256
|
+
else:
|
|
257
|
+
persisted = path if input.partition("://")[1] else input
|
|
258
|
+
source = {"format": fmt, "path": persisted}
|
|
259
|
+
if options is not None:
|
|
260
|
+
source["options"] = options
|
|
261
|
+
entity: Hash = {
|
|
262
|
+
"name": name,
|
|
263
|
+
**({} if domain is None else {"domain": domain}),
|
|
264
|
+
"source": source,
|
|
265
|
+
"sink": {
|
|
266
|
+
"accepted": {"format": "parquet", "path": f"out/accepted/{name}/"},
|
|
267
|
+
"rejected": {"format": "csv", "path": f"out/rejected/{name}/"},
|
|
268
|
+
},
|
|
269
|
+
"policy": {"severity": "reject"},
|
|
270
|
+
"schema": {
|
|
271
|
+
"mismatch": {"missing_columns": "reject_file", "extra_columns": "ignore"},
|
|
272
|
+
"columns": columns,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
return entity, fmt, notes
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class Added(NamedTuple):
|
|
279
|
+
name: str
|
|
280
|
+
format: str
|
|
281
|
+
columns: int
|
|
282
|
+
output: str
|
|
283
|
+
rendered: str | None # the YAML of a dry run
|
|
284
|
+
notes: list[str]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_WORDS = {"true", "false", "True", "False", "TRUE", "FALSE", "null", "Null", "NULL", "~", "y", "Y", "n", "N"} | {
|
|
288
|
+
"yes", "Yes", "YES", "no", "No", "NO", "on", "On", "ON", "off", "Off", "OFF"
|
|
289
|
+
} # fmt: skip
|
|
290
|
+
_SPECIAL = re.compile("[:{}\\[\\],#`\"'\\\\\x00-\x06\t\n\r\x0e-\x1a\x1c-\x1f]")
|
|
291
|
+
_ESCAPES = {i: f"\\u{i:04x}" for i in (*range(0x20), 0x7F)} | {
|
|
292
|
+
0x22: '\\"', 0x5C: "\\\\", 0x08: "\\b", 0x09: "\\t", 0x0A: "\\n", 0x0C: "\\f", 0x0D: "\\r"
|
|
293
|
+
} # fmt: skip
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _scalar(value: object) -> str:
|
|
297
|
+
"""yaml-rust2 `YamlEmitter::emit_node` for a scalar."""
|
|
298
|
+
if isinstance(value, str):
|
|
299
|
+
quote = (
|
|
300
|
+
not value or value[0] == " " or value[-1] == " " or value[0] in "&*?|-<>=!%@"
|
|
301
|
+
or bool(_SPECIAL.search(value)) or value in _WORDS or value.startswith((".", "0x"))
|
|
302
|
+
or bool(_FLOAT.fullmatch(value)) or value.lstrip("+-").lower() in ("inf", "infinity", "nan")
|
|
303
|
+
) # fmt: skip
|
|
304
|
+
return f'"{value.translate(_ESCAPES)}"' if quote else value
|
|
305
|
+
if isinstance(value, bool):
|
|
306
|
+
return str(value).lower()
|
|
307
|
+
if isinstance(value, float):
|
|
308
|
+
# ponytail: yaml-rust2 re-emits a float's source text (`1.50`); this writes `1.5`
|
|
309
|
+
if math.isnan(value):
|
|
310
|
+
return ".nan"
|
|
311
|
+
return {math.inf: ".inf", -math.inf: "-.inf"}.get(value, repr(value))
|
|
312
|
+
return "~" if value is None else str(value)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _node(value: object, level: int) -> str:
|
|
316
|
+
if isinstance(value, list):
|
|
317
|
+
items = cast("list[object]", value)
|
|
318
|
+
if not items:
|
|
319
|
+
return "[]"
|
|
320
|
+
sep = "\n" + " " * (level + 1)
|
|
321
|
+
return sep.join("-" + _val(True, x, level + 1) for x in items)
|
|
322
|
+
if isinstance(value, dict):
|
|
323
|
+
pairs = cast(Hash, value)
|
|
324
|
+
if not pairs:
|
|
325
|
+
return "{}"
|
|
326
|
+
sep = "\n" + " " * (level + 1)
|
|
327
|
+
return sep.join(_scalar(k) + ":" + _val(False, v, level + 1) for k, v in pairs.items()) # fmt: skip
|
|
328
|
+
return _scalar(value)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _val(inline: bool, value: object, level: int) -> str:
|
|
332
|
+
text = _node(value, level)
|
|
333
|
+
block = not inline and text not in ("[]", "{}") and isinstance(value, (list, dict))
|
|
334
|
+
return ("\n" + " " * (level + 1) if block else " ") + text
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _render(doc: object) -> str:
|
|
338
|
+
"""yaml-rust2 `YamlEmitter::dump` without the `---` line, as Rust's `render_yaml`."""
|
|
339
|
+
return _node(doc, -1) + "\n"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _included_domain(
|
|
343
|
+
config: str, root: object, domain: str
|
|
344
|
+
) -> tuple[str, object] | None:
|
|
345
|
+
"""The directory and `defaults` of the included domain file named `domain`."""
|
|
346
|
+
include = cast(Hash, root).get("include") if isinstance(root, dict) else None
|
|
347
|
+
patterns = cast(Hash, include).get("domains") if isinstance(include, dict) else None
|
|
348
|
+
if not isinstance(patterns, list):
|
|
349
|
+
return None
|
|
350
|
+
parent = os.path.dirname(config)
|
|
351
|
+
base = glob_base(Path(parent)) if parent else ""
|
|
352
|
+
for pattern in cast("list[object]", patterns):
|
|
353
|
+
if not isinstance(pattern, str):
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
matches = glob(os.path.join(base, pattern))
|
|
357
|
+
except PatternError as e:
|
|
358
|
+
raise FloeConfigError(f"invalid include.domains pattern {rust_debug(pattern)} in {config}: {e}") from e # fmt: skip
|
|
359
|
+
except FloeError as e:
|
|
360
|
+
raise FloeConfigError(f"failed to match include.domains pattern {rust_debug(pattern)} in {config}: {e}") from e # fmt: skip
|
|
361
|
+
for file in matches:
|
|
362
|
+
try:
|
|
363
|
+
docs: list[object] = load_yaml(Path(file))
|
|
364
|
+
except FloeIoError as e:
|
|
365
|
+
raise FloeIoError(f"failed to read domain file: {e}") from e
|
|
366
|
+
except FloeError as e:
|
|
367
|
+
raise FloeConfigError(f"failed to parse domain file {file}: {e}") from e
|
|
368
|
+
doc = docs[0] if docs else None
|
|
369
|
+
if isinstance(doc, dict) and cast(Hash, doc).get("name") == domain:
|
|
370
|
+
return os.path.dirname(file), cast(Hash, doc).get("defaults", {})
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _matches_domain_include(config: str, root: object, path: str) -> bool:
|
|
375
|
+
"""Whether `assemble_includes` would load `path` as a domain file."""
|
|
376
|
+
include = cast(Hash, root).get("include") if isinstance(root, dict) else None
|
|
377
|
+
patterns = cast(Hash, include).get("domains") if isinstance(include, dict) else None
|
|
378
|
+
base = glob_base(Path(os.path.dirname(config)))
|
|
379
|
+
return any(
|
|
380
|
+
Pattern(os.path.join(base, p)).matches_with(path, _COMPONENT_WISE)
|
|
381
|
+
for p in cast("list[object]", patterns)
|
|
382
|
+
if isinstance(p, str)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _strip_defaults(entity: Hash, defaults: Hash) -> None:
|
|
387
|
+
for key, default in defaults.items():
|
|
388
|
+
if key not in entity:
|
|
389
|
+
continue
|
|
390
|
+
value = entity[key]
|
|
391
|
+
if isinstance(value, dict) and isinstance(default, dict):
|
|
392
|
+
_strip_defaults(cast(Hash, value), cast(Hash, default))
|
|
393
|
+
if value:
|
|
394
|
+
continue
|
|
395
|
+
del entity[key]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _validate(config: Path, extra_entity: Hash | None = None) -> None:
|
|
399
|
+
root = parse_config(config, extra_entity=extra_entity)
|
|
400
|
+
validate_domains(root)
|
|
401
|
+
validate_entities(root)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _validate_text(text: str, target: str) -> None:
|
|
405
|
+
"""Validate `text` as a config placed next to `target` (Rust `validate_generated_config`)."""
|
|
406
|
+
try:
|
|
407
|
+
fd, tmp = tempfile.mkstemp(prefix=".tmp", dir=os.path.dirname(target) or ".")
|
|
408
|
+
except OSError:
|
|
409
|
+
fd, tmp = tempfile.mkstemp(prefix=".tmp")
|
|
410
|
+
try:
|
|
411
|
+
with os.fdopen(fd, "wb") as f:
|
|
412
|
+
f.write(text.encode())
|
|
413
|
+
_validate(Path(tmp))
|
|
414
|
+
finally:
|
|
415
|
+
os.unlink(tmp)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _write_new(path: str, text: str) -> None:
|
|
419
|
+
"""Stage a temp file and hard-link it in, so the entity file appears whole and is never clobbered."""
|
|
420
|
+
tmp = os.path.join(
|
|
421
|
+
os.path.dirname(path), f".floe-add-entity-{secrets.token_hex(6)}.yml"
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
425
|
+
except OSError as e:
|
|
426
|
+
raise FloeIoError(f"failed to stage entity file: {_os(e)}") from e
|
|
427
|
+
try: # a partial `.yml` left behind would be loaded as an entity
|
|
428
|
+
try:
|
|
429
|
+
with os.fdopen(fd, "wb") as f:
|
|
430
|
+
f.write(text.encode())
|
|
431
|
+
except OSError as e:
|
|
432
|
+
raise FloeIoError(f"failed to stage entity file: {_os(e)}") from e
|
|
433
|
+
try:
|
|
434
|
+
os.link(tmp, path)
|
|
435
|
+
except OSError as e:
|
|
436
|
+
raise FloeIoError(f"failed to write entity file: {_os(e)}") from e
|
|
437
|
+
finally:
|
|
438
|
+
os.unlink(tmp)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def add_entity(
|
|
442
|
+
config: str, input: str, fmt: str | None, name: str | None, domain: str | None,
|
|
443
|
+
source_ref: str | None, resource: str | None, output: str | None, dry_run: bool,
|
|
444
|
+
) -> Added: # fmt: skip
|
|
445
|
+
if (source_ref is None) != (resource is None):
|
|
446
|
+
raise FloeConfigError("--source and --resource must be provided together")
|
|
447
|
+
entity, fmt, notes = infer_entity(input, fmt, name, domain, source_ref, resource)
|
|
448
|
+
name = cast(str, entity["name"])
|
|
449
|
+
columns = len(cast("list[object]", cast(Hash, entity["schema"])["columns"]))
|
|
450
|
+
|
|
451
|
+
exists = os.path.exists(config)
|
|
452
|
+
try: # D21 (#619): a new config targets this Floe major.minor
|
|
453
|
+
docs: list[object] = load_yaml(Path(config)) if exists else [{"version": ".".join(__version__.split(".")[:2]), "entities": []}] # fmt: skip
|
|
454
|
+
except FloeIoError as e:
|
|
455
|
+
raise FloeIoError(f"failed to read config at {config}: {e}") from e
|
|
456
|
+
included = None
|
|
457
|
+
if output is None and domain is not None and exists:
|
|
458
|
+
included = _included_domain(config, docs[0] if docs else None, domain)
|
|
459
|
+
if included is not None:
|
|
460
|
+
directory, defaults = included
|
|
461
|
+
if "/" in name or "\\" in name:
|
|
462
|
+
raise FloeConfigError(f"entity name cannot contain a path separator for an included domain at {directory}") # fmt: skip
|
|
463
|
+
output = os.path.join(directory, f"{name}.yml")
|
|
464
|
+
if os.path.exists(output):
|
|
465
|
+
raise FloeIoError(f"refusing to overwrite existing entity file at {output}")
|
|
466
|
+
if _matches_domain_include(config, docs[0], output):
|
|
467
|
+
raise FloeConfigError(f"entity file {output} would also match an include.domains pattern and load as a domain; use --output or a different name") # fmt: skip
|
|
468
|
+
if isinstance(defaults, dict):
|
|
469
|
+
# Rust first drops `name`, `schema.columns` and `primary_key` from the defaults; D7 rejects those
|
|
470
|
+
_strip_defaults(entity, cast(Hash, defaults))
|
|
471
|
+
entity.pop("domain", None)
|
|
472
|
+
rendered = _render(entity)
|
|
473
|
+
try:
|
|
474
|
+
# the reloaded bytes, as Rust: a name like `0o123` is written bare and reloads as an integer
|
|
475
|
+
[reloaded] = parse_yaml(rendered)
|
|
476
|
+
_validate(Path(config), {**cast(Hash, reloaded), "domain": domain})
|
|
477
|
+
except FloeError as e:
|
|
478
|
+
raise FloeConfigError(f"generated entity file {output} failed validation: {e}") from e # fmt: skip
|
|
479
|
+
if not dry_run:
|
|
480
|
+
_write_new(output, rendered)
|
|
481
|
+
return Added(name, fmt, columns, output, rendered if dry_run else None, notes)
|
|
482
|
+
|
|
483
|
+
if len(docs) > 1:
|
|
484
|
+
raise FloeConfigError("YAML contains multiple documents; expected one")
|
|
485
|
+
root = docs[0] if docs else None
|
|
486
|
+
if not isinstance(root, dict):
|
|
487
|
+
raise FloeConfigError("config root must be a YAML mapping")
|
|
488
|
+
root = cast(Hash, root)
|
|
489
|
+
version = root.get("version")
|
|
490
|
+
try: # 0.3+ entity identity is (domain, name) (#464); older configs compare names only
|
|
491
|
+
qualified = isinstance(version, str) and config_version(version) >= V0_3
|
|
492
|
+
except FloeConfigError:
|
|
493
|
+
qualified = False
|
|
494
|
+
if not isinstance(entities := root.setdefault("entities", []), list):
|
|
495
|
+
raise FloeConfigError("root.entities must be a YAML sequence")
|
|
496
|
+
entities = cast("list[object]", entities)
|
|
497
|
+
|
|
498
|
+
def field(e: object, key: str) -> object:
|
|
499
|
+
value = cast(Hash, e).get(key) if isinstance(e, dict) else None
|
|
500
|
+
return value if isinstance(value, str) else None
|
|
501
|
+
|
|
502
|
+
for e in entities:
|
|
503
|
+
if field(e, "name") == name and (not qualified or field(e, "domain") == domain):
|
|
504
|
+
raise FloeConfigError(f"entity already exists: {name}")
|
|
505
|
+
entities.append(entity)
|
|
506
|
+
rendered = _render(root)
|
|
507
|
+
output = config if output is None else output
|
|
508
|
+
if not dry_run and (parent := os.path.dirname(output)):
|
|
509
|
+
os.makedirs(parent, exist_ok=True)
|
|
510
|
+
_validate_text(rendered, config if dry_run else output)
|
|
511
|
+
if not dry_run:
|
|
512
|
+
try:
|
|
513
|
+
Path(output).write_bytes(rendered.encode())
|
|
514
|
+
except OSError as e:
|
|
515
|
+
raise FloeIoError(f"failed to write config at {output}: {_os(e)}") from e
|
|
516
|
+
return Added(name, fmt, columns, output, rendered if dry_run else None, notes)
|
floe/checks/cast.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""String to typed casts and cast-mismatch errors (Rust: `checks/cast.rs`, `io/format.rs`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
CAST_ERROR_MESSAGE = "invalid value for target type"
|
|
9
|
+
|
|
10
|
+
_DTYPES: dict[str, pl.DataType] = {
|
|
11
|
+
"int8": pl.Int8(), "int16": pl.Int16(), "int32": pl.Int32(), "int64": pl.Int64(),
|
|
12
|
+
"int": pl.Int64(), "integer": pl.Int64(), "long": pl.Int64(),
|
|
13
|
+
"uint8": pl.UInt8(), "uint16": pl.UInt16(), "uint32": pl.UInt32(), "uint64": pl.UInt64(),
|
|
14
|
+
"float32": pl.Float32(),
|
|
15
|
+
"float64": pl.Float64(), "float": pl.Float64(), "double": pl.Float64(),
|
|
16
|
+
"number": pl.Float64(),
|
|
17
|
+
} # fmt: skip
|
|
18
|
+
|
|
19
|
+
_DECIMAL = re.compile(r"decimal\(\s*(\d+)\s*,\s*(\d+)\s*\)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decimal_type(column_type: str) -> pl.Decimal | None:
|
|
23
|
+
"""`decimal(p,s)` as an exact `pl.Decimal`, or None when `kind` is not one.
|
|
24
|
+
|
|
25
|
+
Raises ValueError for a precision outside 1..38 or a scale outside 0..precision.
|
|
26
|
+
"""
|
|
27
|
+
# matched on the declared type: `_canonical` would drop the sign of `decimal(4,-2)`
|
|
28
|
+
if not (m := _DECIMAL.fullmatch(column_type.lower())):
|
|
29
|
+
return None
|
|
30
|
+
precision, scale = int(m[1]), int(m[2])
|
|
31
|
+
if not 1 <= precision <= 38 or scale > precision:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"decimal(p,s) needs 1 <= p <= 38 and 0 <= s <= p, got {column_type}"
|
|
34
|
+
)
|
|
35
|
+
return pl.Decimal(precision, scale)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Polars 2.0 has no string -> date/datetime cast, so both are parsed by regex (decision D9, #619):
|
|
39
|
+
# groups 1-3 year/month/day, 4-7 hour/minute/second/fraction, 8-10 offset sign/hour/minute.
|
|
40
|
+
_DATE = r"\+?([0-9]{1,4})-([0-9]{1,2})-([0-9]{1,2})"
|
|
41
|
+
_TIME = r"T([0-9]{1,2}):([0-9]{1,2})(?::([0-9]{1,2})(?:\.([0-9]*))?)?"
|
|
42
|
+
_OFFSET = r"(?:[Zz]|([+-])([0-9]{2})(?::?([0-9]{2}))?)?"
|
|
43
|
+
_DATE_RE = f"^{_DATE}$"
|
|
44
|
+
_DATETIME_RE = f"^{_DATE}{_TIME}{_OFFSET}$"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _groups(raw: pl.Expr, pattern: str, count: int) -> list[pl.Expr]:
|
|
48
|
+
groups = raw.str.strip_chars().str.extract_groups(pattern)
|
|
49
|
+
return [groups.struct.field(str(i)) for i in range(1, count + 1)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _int(field: pl.Expr) -> pl.Expr:
|
|
53
|
+
return field.cast(pl.Int32)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ymd(g: list[pl.Expr]) -> pl.Expr:
|
|
57
|
+
return pl.format(
|
|
58
|
+
"{}-{}-{}", g[0].str.zfill(4), g[1].str.zfill(2), g[2].str.zfill(2)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _date(raw: pl.Expr) -> pl.Expr:
|
|
63
|
+
g = _groups(raw, _DATE_RE, 3)
|
|
64
|
+
return pl.when(_int(g[0]) >= 1).then(_ymd(g).str.to_date("%Y-%m-%d", strict=False))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _datetime(raw: pl.Expr) -> pl.Expr:
|
|
68
|
+
g = _groups(raw, _DATETIME_RE, 10)
|
|
69
|
+
second = _int(g[5]).fill_null(0)
|
|
70
|
+
# fraction truncated (not rounded) to milliseconds
|
|
71
|
+
millis = _int(g[6].str.slice(0, 3).str.pad_end(3, "0")).fill_null(0)
|
|
72
|
+
off_hour, off_minute = _int(g[8]).fill_null(0), _int(g[9]).fill_null(0)
|
|
73
|
+
off_sign = pl.when(g[7] == "-").then(-1).otherwise(1)
|
|
74
|
+
valid = (_int(g[0]) >= 1) & (second <= 60) & (off_hour <= 23) & (off_minute <= 59)
|
|
75
|
+
local = pl.format(
|
|
76
|
+
"{} {}:{}", _ymd(g), g[3].str.zfill(2), g[4].str.zfill(2)
|
|
77
|
+
).str.to_datetime("%Y-%m-%d %H:%M", time_unit="ms", strict=False)
|
|
78
|
+
# second 60 rolls over into the next minute; the offset is subtracted to reach UTC
|
|
79
|
+
shift = pl.duration(
|
|
80
|
+
seconds=second,
|
|
81
|
+
milliseconds=millis,
|
|
82
|
+
hours=-off_sign * off_hour,
|
|
83
|
+
minutes=-off_sign * off_minute,
|
|
84
|
+
time_unit="ms",
|
|
85
|
+
)
|
|
86
|
+
utc = local + shift
|
|
87
|
+
# the 1..9999 year range applies to the final value (rollover, offset), not only the input
|
|
88
|
+
return pl.when(valid & utc.dt.year().is_between(1, 9999)).then(utc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _boolean(raw: pl.Expr) -> pl.Expr:
|
|
92
|
+
value = raw.str.strip_chars().str.to_lowercase()
|
|
93
|
+
return (
|
|
94
|
+
pl.when(value.is_in(["true", "1"]))
|
|
95
|
+
.then(True)
|
|
96
|
+
.when(value.is_in(["false", "0"]))
|
|
97
|
+
.then(False)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cast_column(
|
|
102
|
+
name: str,
|
|
103
|
+
column_type: str,
|
|
104
|
+
cast_mode: str = "strict",
|
|
105
|
+
dtype: pl.DataType | type[pl.DataType] = pl.String,
|
|
106
|
+
) -> tuple[pl.Expr, pl.Expr]:
|
|
107
|
+
"""Return `(typed, cast_error_mask)` expressions over the column `name`, read as `dtype`.
|
|
108
|
+
|
|
109
|
+
A value that is present but does not parse becomes null; the mask (named `_e_cast_<name>`)
|
|
110
|
+
flags it, except in `cast_mode: coerce` and for string columns.
|
|
111
|
+
|
|
112
|
+
`dtype` is the input column's dtype. Parquet, ORC and Avro keep native types (D15): anything
|
|
113
|
+
but `pl.String` is cast natively, as Rust's `cast_df_to_schema` does. That is Polars'
|
|
114
|
+
non-strict cast, so overflow, a float outside the integer range or NaN, and a date that does
|
|
115
|
+
not fit become null (a `cast_error`), while 2.5 into an int truncates to 2 and an int into a
|
|
116
|
+
bool is `!= 0`. A pair Polars cannot cast (date or datetime into bool, a list) raises, and
|
|
117
|
+
aborts the run as in Rust. D17: unlike Rust, a float with a fraction into an integer type is a
|
|
118
|
+
`cast_error` in strict mode (2.0 is accepted); in coerce mode it truncates as in Rust.
|
|
119
|
+
Timezone-aware datetimes lose the zone and keep their UTC value.
|
|
120
|
+
"""
|
|
121
|
+
raw = pl.col(name)
|
|
122
|
+
native = dtype != pl.String
|
|
123
|
+
kind = column_type.lower().replace("-", "").replace("_", "")
|
|
124
|
+
if kind in ("string", "str", "text"):
|
|
125
|
+
text = raw.cast(pl.String) if native else raw
|
|
126
|
+
if native and dtype.is_float(): # Rust writes 1e20, Polars 1e+20
|
|
127
|
+
text = text.str.replace("e+", "e", literal=True)
|
|
128
|
+
return text, pl.lit(False).alias(f"_e_cast_{name}")
|
|
129
|
+
fractional = None
|
|
130
|
+
if (exact := decimal_type(column_type)) is not None:
|
|
131
|
+
typed = raw.cast(exact, strict=False)
|
|
132
|
+
if not native: # strict: an exponent or digits beyond the scale are a cast_error, not a silent rounding
|
|
133
|
+
fractional = raw.str.contains(
|
|
134
|
+
rf"[eE]|\.[0-9]{{{exact.scale}}}[0-9]*[1-9]"
|
|
135
|
+
).fill_null(False)
|
|
136
|
+
elif kind in _DTYPES:
|
|
137
|
+
typed = raw.cast(_DTYPES[kind], strict=False)
|
|
138
|
+
if native and dtype.is_float() and _DTYPES[kind].is_integer():
|
|
139
|
+
# D17; NaN and inf are null already
|
|
140
|
+
fractional = (raw != raw.floor()).fill_null(False)
|
|
141
|
+
elif kind in ("boolean", "bool"):
|
|
142
|
+
typed = raw.cast(pl.Boolean, strict=False) if native else _boolean(raw)
|
|
143
|
+
elif kind == "date":
|
|
144
|
+
typed = raw.cast(pl.Date, strict=False) if native else _date(raw)
|
|
145
|
+
elif kind in ("datetime", "timestamp"):
|
|
146
|
+
typed = raw.cast(pl.Datetime("ms"), strict=False) if native else _datetime(raw)
|
|
147
|
+
else:
|
|
148
|
+
raise ValueError(f"unsupported column type: {column_type}")
|
|
149
|
+
typed = typed.alias(name)
|
|
150
|
+
mask = pl.lit(False)
|
|
151
|
+
if cast_mode != "coerce":
|
|
152
|
+
mask = raw.is_not_null() & typed.is_null()
|
|
153
|
+
if fractional is not None:
|
|
154
|
+
mask = mask | fractional
|
|
155
|
+
return typed, mask.alias(f"_e_cast_{name}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def json_escape(value: str) -> str:
|
|
159
|
+
"""The body of a JSON string: every control character escaped (#983), non-ASCII kept raw."""
|
|
160
|
+
return json.dumps(value, ensure_ascii=False)[1:-1]
|