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/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]