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/read/json.py ADDED
@@ -0,0 +1,236 @@
1
+ """JSON reader: every selected column as a raw string (Rust: `io/read/json.rs`, `json_selector.rs`)."""
2
+
3
+ import json
4
+ import math
5
+ from collections.abc import Iterator, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, NoReturn, Self
9
+
10
+ import polars as pl
11
+
12
+ from floe.checks.cast import decimal_type
13
+ from floe.config.entity import WHITE_SPACE
14
+ from floe.config.types import ColumnConfig, SourceOptions
15
+ from floe.errors import FloeIoError
16
+
17
+ Selector = list[str | int] # a field name or an array index
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class JsonChunk:
22
+ """`offset` is the whole-file index of the chunk's first row (add it to the row index)."""
23
+
24
+ offset: int
25
+ df: pl.DataFrame
26
+
27
+
28
+ def parse_selector(value: str) -> Selector:
29
+ """A path of fields and array indexes, e.g. `a.b[0].c` (a `.` right after `]` is optional)."""
30
+ trimmed = value.strip()
31
+ if not trimmed:
32
+ raise ValueError("selector is empty")
33
+ tokens: Selector = []
34
+ buf = ""
35
+ after_index = False
36
+ chars = iter(trimmed)
37
+ for ch in chars:
38
+ if ch == ".":
39
+ if buf:
40
+ tokens.append(buf)
41
+ buf = ""
42
+ elif not after_index:
43
+ raise ValueError("selector contains empty field")
44
+ after_index = False
45
+ elif ch == "[":
46
+ if buf:
47
+ tokens.append(buf)
48
+ buf = ""
49
+ digits = ""
50
+ for nxt in chars:
51
+ if nxt == "]":
52
+ break
53
+ digits += nxt
54
+ if not digits:
55
+ raise ValueError("selector index is empty")
56
+ if not (digits.isascii() and digits.isdigit()):
57
+ raise ValueError(f"selector index is not numeric: {digits}")
58
+ if int(digits) >= 1 << 64:
59
+ raise ValueError(f"selector index is invalid: {digits}")
60
+ tokens.append(int(digits))
61
+ after_index = True
62
+ elif ch == "]":
63
+ raise ValueError("selector contains unexpected ']'")
64
+ else:
65
+ buf += ch
66
+ after_index = False
67
+ if buf:
68
+ tokens.append(buf)
69
+ return tokens
70
+
71
+
72
+ def _is(value: Any, kind: type) -> bool:
73
+ """isinstance without narrowing: pyright strict rejects the Unknown containers it infers."""
74
+ return isinstance(value, kind)
75
+
76
+
77
+ def _compact(value: Any) -> str:
78
+ return json.dumps(
79
+ value, separators=(",", ":"), ensure_ascii=False, sort_keys=True
80
+ ) # serde_json sorts keys
81
+
82
+
83
+ class _Number(float):
84
+ """A JSON float that keeps its token, so a `decimal(p,s)` column reads it without rounding."""
85
+
86
+ text: str
87
+
88
+ def __new__(cls, text: str) -> Self:
89
+ self = super().__new__(cls, text)
90
+ self.text = text
91
+ return self
92
+
93
+
94
+ def _cell(
95
+ value: Any,
96
+ tokens: Selector,
97
+ source: str,
98
+ cast_mode: str,
99
+ where: str,
100
+ exact: bool = False,
101
+ ) -> str | None:
102
+ """Missing paths and JSON null are null; objects and arrays are an error unless `coerce`."""
103
+ row: Any = value
104
+ for token in tokens:
105
+ if isinstance(token, str):
106
+ if not _is(row, dict) or token not in row:
107
+ return None
108
+ elif not _is(row, list) or token >= len(row):
109
+ return None
110
+ row = row[token]
111
+ if row is None:
112
+ return None
113
+ if _is(row, dict) or _is(row, list):
114
+ if cast_mode != "coerce":
115
+ raise FloeIoError(f"non-scalar value for selector {source} at {where}")
116
+ return _compact(row)
117
+ if isinstance(row, bool):
118
+ return "true" if row else "false"
119
+ if exact and isinstance(row, _Number):
120
+ return row.text
121
+ return str(row)
122
+
123
+
124
+ def _reject_constant(name: str) -> NoReturn:
125
+ raise ValueError(f"invalid number: {name}")
126
+
127
+
128
+ def _float(text: str) -> float:
129
+ value = _Number(text)
130
+ if math.isinf(value):
131
+ raise ValueError("number out of range")
132
+ return value
133
+
134
+
135
+ def _int(text: str) -> float:
136
+ """serde reads integers beyond i64/u64 as f64."""
137
+ value = int(text)
138
+ return value if -(1 << 63) <= value < 1 << 64 else _float(text)
139
+
140
+
141
+ def json_loads(text: str) -> Any:
142
+ """`json.loads` with serde's rules: no NaN/Infinity, big numbers kept, unpaired surrogates rejected."""
143
+ value = json.loads(
144
+ text, parse_constant=_reject_constant, parse_float=_float, parse_int=_int
145
+ )
146
+ # serde rejects unpaired surrogate escapes; Python keeps them and only fails later
147
+ # ponytail: a second pass over each value; a parse_string hook would avoid it
148
+ json.dumps(value, ensure_ascii=False).encode("utf-8")
149
+ return value
150
+
151
+
152
+ def json_records(path: Path, mode: str) -> Iterator[tuple[str, Any]]:
153
+ """Yield (location, object) per row; `where` is `line N` for ndjson, `index N` for arrays."""
154
+ try:
155
+ if mode == "ndjson":
156
+ with path.open("rb") as handle:
157
+ for number, raw in enumerate(handle, 1):
158
+ try:
159
+ line = raw.decode("utf-8").strip(WHITE_SPACE) # Rust str::trim
160
+ if not line:
161
+ continue
162
+ value = json_loads(line)
163
+ except ValueError as err: # includes UnicodeDecodeError
164
+ raise FloeIoError(
165
+ f"json parse error at line {number}: {err}"
166
+ ) from err
167
+ if not _is(value, dict):
168
+ raise FloeIoError(f"expected json object at line {number}")
169
+ yield f"line {number}", value
170
+ return
171
+ # ponytail: array mode parses the whole file in memory, as Rust; stream if arrays outgrow it
172
+ try:
173
+ array: Any = json_loads(path.read_text(encoding="utf-8"))
174
+ except ValueError as err:
175
+ raise FloeIoError(f"json parse error: {err}") from err
176
+ except OSError as err:
177
+ raise FloeIoError(f"failed to read json at {path}: {err}") from err
178
+ if not _is(array, list):
179
+ raise FloeIoError("expected json array at root")
180
+ for index, value in enumerate(array):
181
+ if not _is(value, dict):
182
+ raise FloeIoError(f"expected json object at index {index}")
183
+ yield f"index {index}", value
184
+
185
+
186
+ def read_json(
187
+ path: Path,
188
+ options: SourceOptions,
189
+ columns: Sequence[ColumnConfig],
190
+ cast_mode: str = "strict",
191
+ chunk_rows: int = 1_000_000,
192
+ ) -> Iterator[JsonChunk]:
193
+ """Yield the rows in chunks of at most `chunk_rows`, columns named by selector; always at least one.
194
+
195
+ `options.json_mode` is `array` (default) or `ndjson`. A selector that finds nothing is null.
196
+ Objects and arrays are an error unless `cast_mode` is `coerce`, which keeps their compact JSON.
197
+ Scalars are stringified with Python's `str`; Polars would render `1.0` as `1`, so it is not used.
198
+ """
199
+ mode = options.json_mode or "array"
200
+ if mode not in ("array", "ndjson"):
201
+ raise FloeIoError(
202
+ f"unsupported source.options.json_mode={mode} (allowed: array, ndjson)"
203
+ )
204
+ names = [c.source if c.source is not None else c.name for c in columns]
205
+ if len(set(names)) != len(names):
206
+ dup = next(n for n in names if names.count(n) > 1)
207
+ raise FloeIoError(f"duplicate json selector source: {dup}")
208
+ plans: list[Selector] = []
209
+ for name in names:
210
+ try:
211
+ plans.append(parse_selector(name))
212
+ except ValueError as err:
213
+ raise FloeIoError(f"invalid selector {name}: {err}") from err
214
+
215
+ def frame(rows: list[list[str | None]]) -> pl.DataFrame:
216
+ cols = zip(*rows, strict=True) if rows else [() for _ in names]
217
+ return pl.DataFrame(
218
+ {n: pl.Series(n, list(c), dtype=pl.String) for n, c in zip(names, cols)}
219
+ )
220
+
221
+ exact = [decimal_type(c.column_type) is not None for c in columns]
222
+ rows: list[list[str | None]] = []
223
+ done = 0
224
+ for where, value in json_records(path, mode):
225
+ rows.append(
226
+ [
227
+ _cell(value, p, n, cast_mode, where, e)
228
+ for p, n, e in zip(plans, names, exact)
229
+ ]
230
+ )
231
+ if len(rows) == chunk_rows:
232
+ yield JsonChunk(done, frame(rows))
233
+ done += len(rows)
234
+ rows = []
235
+ if rows or done == 0:
236
+ yield JsonChunk(done, frame(rows))
floe/read/orc.py ADDED
@@ -0,0 +1,42 @@
1
+ """ORC reader: native column types, one chunk per stripe (Rust: `io/read/orc.rs`)."""
2
+ # pyarrow ships no type stubs, so strict pyright cannot see its types.
3
+ # pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false
4
+ # pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false
5
+
6
+ from collections.abc import Collection, Iterator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import polars as pl
11
+ import pyarrow as pa
12
+ import pyarrow.orc as paorc
13
+
14
+ from floe.errors import FloeIoError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class OrcChunk:
19
+ """`offset` is the whole-file index of the chunk's first row."""
20
+
21
+ offset: int
22
+ df: pl.DataFrame
23
+
24
+
25
+ def read_orc(path: Path, columns: Collection[str] = ()) -> Iterator[OrcChunk]:
26
+ """Yield one chunk per stripe; always at least one (maybe empty).
27
+
28
+ Columns keep their native types (casting them is #758, D15). `columns` projects to the
29
+ declared columns that exist in the file; none of them present reads everything, as Rust.
30
+ """
31
+ try:
32
+ orc = paorc.ORCFile(path)
33
+ picked = [name for name in orc.schema.names if name in columns] or None
34
+ done = 0
35
+ for i in range(orc.nstripes):
36
+ df = pl.DataFrame(orc.read_stripe(i, columns=picked))
37
+ yield OrcChunk(done, df)
38
+ done += df.height
39
+ if done == 0:
40
+ yield OrcChunk(0, pl.DataFrame(orc.read(columns=picked)))
41
+ except (pl.exceptions.PolarsError, pa.ArrowException, OSError) as err:
42
+ raise FloeIoError(f"orc read failed: {err}") from err
floe/read/parquet.py ADDED
@@ -0,0 +1,43 @@
1
+ """Parquet reader: native column types, in bounded chunks (Rust: `io/read/parquet.rs`)."""
2
+
3
+ from collections.abc import Collection, Iterator
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import polars as pl
8
+
9
+ from floe.errors import FloeIoError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ParquetChunk:
14
+ """`offset` is the whole-file index of the chunk's first row."""
15
+
16
+ offset: int
17
+ df: pl.DataFrame
18
+
19
+
20
+ def read_parquet(
21
+ path: Path, columns: Collection[str] = (), chunk_rows: int = 1_000_000
22
+ ) -> Iterator[ParquetChunk]:
23
+ """Yield the file in chunks of at most `chunk_rows` rows; always at least one (maybe empty).
24
+
25
+ Columns keep their native types (casting them is #758, D15). `columns` projects to the
26
+ declared columns that exist in the file; none of them present reads everything, as Rust.
27
+ """
28
+ try:
29
+ lf = pl.scan_parquet(path)
30
+ picked = [name for name in lf.collect_schema().names() if name in columns]
31
+ except (pl.exceptions.PolarsError, OSError) as err:
32
+ raise FloeIoError(f"failed to scan parquet at {path}: {err}") from err
33
+ if picked:
34
+ lf = lf.select(picked)
35
+ try:
36
+ done = 0
37
+ for df in lf.collect_batches(chunk_size=chunk_rows):
38
+ yield ParquetChunk(done, df)
39
+ done += df.height
40
+ if done == 0:
41
+ yield ParquetChunk(0, lf.head(0).collect())
42
+ except (pl.exceptions.PolarsError, OSError) as err:
43
+ raise FloeIoError(f"parquet read failed: {err}") from err
floe/read/xlsx.py ADDED
@@ -0,0 +1,75 @@
1
+ """XLSX reader: every cell as a raw string (Rust: `io/read/xlsx.rs`), via `fastexcel` (calamine)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import fastexcel
6
+ import polars as pl
7
+
8
+ from floe.config.types import SourceOptions
9
+ from floe.errors import FloeIoError
10
+
11
+
12
+ def _fail(rule: str, message: str) -> FloeIoError:
13
+ return FloeIoError(f"{rule}: {message}")
14
+
15
+
16
+ def _header(cells: tuple[str | None, ...]) -> list[str]:
17
+ """Trimmed names; blank cells become `column_N`; repeats become `<name>_dup_<count>` (as Rust)."""
18
+ counts: dict[str, int] = {}
19
+ used: set[str] = set()
20
+ names: list[str] = []
21
+ for index, cell in enumerate(cells):
22
+ name = (cell or "").strip() or f"column_{index + 1}"
23
+ count = counts.get(name, 0) + 1
24
+ candidate = name if count == 1 else f"{name}_dup_{count}"
25
+ while candidate in used:
26
+ count += 1
27
+ candidate = f"{name}_dup_{count}"
28
+ counts[name] = count
29
+ used.add(candidate)
30
+ names.append(candidate)
31
+ return names
32
+
33
+
34
+ def read_xlsx(path: Path, options: SourceOptions) -> pl.DataFrame:
35
+ """Read the whole sheet; rows and columns count from the sheet's used range, as in Rust.
36
+
37
+ The data rows start at `data_row` (default `header_row + 1`). Numbers, booleans and dates are
38
+ rendered by calamine (see the tests for the one rendering that differs from Rust: dates).
39
+ """
40
+ # ponytail: calamine loads the whole sheet in memory, so there is one frame, not chunks.
41
+ header_row = 1 if options.header_row is None else options.header_row
42
+ data_row = header_row + 1 if options.data_row is None else options.data_row
43
+ try:
44
+ book = fastexcel.read_excel(path)
45
+ except fastexcel.FastExcelError as err:
46
+ raise _fail("xlsx_read_error", f"failed to open xlsx at {path}: {err}") from err
47
+ names = book.sheet_names
48
+ if not names:
49
+ raise _fail("xlsx_read_error", f"no sheets found in {path}")
50
+ sheet = names[0] if options.sheet is None else options.sheet
51
+ if sheet not in names:
52
+ raise _fail(
53
+ "xlsx_sheet_error",
54
+ f"sheet {sheet} not found in {path} (available: {', '.join(names)})",
55
+ )
56
+ try:
57
+ # the eager=True overload of fastexcel's stubs returns an untyped pyarrow table
58
+ loaded = book.load_sheet( # pyright: ignore[reportUnknownMemberType]
59
+ sheet, header_row=None, dtypes="string"
60
+ )
61
+ raw = loaded.to_polars()
62
+ except fastexcel.FastExcelError as err:
63
+ raise _fail(
64
+ "xlsx_read_error", f"failed to read sheet {sheet} in {path}: {err}"
65
+ ) from err
66
+ if raw.width == 0:
67
+ raise _fail("xlsx_read_error", f"sheet is empty in {path}")
68
+ header_idx = max(header_row - 1, 0)
69
+ if header_idx >= raw.height:
70
+ raise _fail(
71
+ "xlsx_read_error",
72
+ f"header_row={header_row} is outside sheet bounds in {path}",
73
+ )
74
+ names = _header(raw.row(header_idx))
75
+ return raw.slice(max(data_row - 1, 0)).rename(dict(zip(raw.columns, names)))
floe/read/xml.py ADDED
@@ -0,0 +1,227 @@
1
+ """XML reader: every selected column as a raw string, streamed in bounded chunks (Rust: `io/read/xml.rs`)."""
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterator, Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from xml.etree import ElementTree as ET
8
+
9
+ import polars as pl
10
+
11
+ from floe.config.types import ColumnConfig, SourceOptions
12
+ from floe.errors import FloeConfigError, FloeIoError
13
+
14
+ _READ_SIZE = 1 << 16
15
+ Selector = list[tuple[bool, str]] # (is_attribute, name)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class XmlChunk:
20
+ """`offset` is the whole-file index of the chunk's first row (add it to the row index)."""
21
+
22
+ offset: int
23
+ df: pl.DataFrame
24
+
25
+
26
+ def parse_selector(value: str) -> Selector:
27
+ """A `.` or `/` separated path of element names, optionally ending in `@attribute`."""
28
+ trimmed = value.strip()
29
+ if not trimmed:
30
+ raise ValueError("selector is empty")
31
+ parts = [p.strip() for p in re.split(r"[./]", trimmed)]
32
+ if "" in parts:
33
+ raise ValueError("selector contains empty token")
34
+ tokens: Selector = []
35
+ for index, part in enumerate(parts):
36
+ if part.startswith("@"):
37
+ if index + 1 != len(parts):
38
+ raise ValueError("attribute selector must be final token")
39
+ attribute = part.lstrip("@").strip()
40
+ if not attribute:
41
+ raise ValueError("attribute selector is empty")
42
+ tokens.append((True, attribute))
43
+ elif "@" in part:
44
+ raise ValueError(f"invalid selector token: {part}")
45
+ else:
46
+ tokens.append((False, part))
47
+ return tokens
48
+
49
+
50
+ def _matches(element: ET.Element, tag: str, namespace: str | None) -> bool:
51
+ prefix, _, local = tag.rpartition(":")
52
+ ns, _, name = (
53
+ element.tag[1:].partition("}")
54
+ if element.tag.startswith("{")
55
+ else ("", "", element.tag)
56
+ )
57
+ if name != local:
58
+ return False
59
+ if prefix and namespace is None:
60
+ return True
61
+ return namespace is None or ns == namespace
62
+
63
+
64
+ def _text(element: ET.Element) -> str | None:
65
+ return "".join(element.itertext()).strip() or None
66
+
67
+
68
+ def _evaluate(
69
+ row: ET.Element, tokens: Selector, namespace: str | None, value_tag: str | None
70
+ ) -> str | None:
71
+ node = row
72
+ for is_attribute, name in tokens:
73
+ if is_attribute:
74
+ return node.get(name)
75
+ child = next((c for c in node if _matches(c, name, namespace)), None)
76
+ if child is None:
77
+ return None
78
+ node = child
79
+ if value_tag:
80
+ node = next(
81
+ (
82
+ d
83
+ for d in node.iter()
84
+ if d is not node and _matches(d, value_tag, namespace)
85
+ ),
86
+ node,
87
+ )
88
+ return _text(node)
89
+
90
+
91
+ class _Rows:
92
+ """Parser target: evaluates each finished row element, then clears it (bounded memory)."""
93
+
94
+ def __init__(
95
+ self,
96
+ evaluate: Callable[[ET.Element], list[str | None]],
97
+ row_tag: str,
98
+ namespace: str | None,
99
+ ) -> None:
100
+ self.builder = ET.TreeBuilder()
101
+ self.evaluate, self.row_tag, self.namespace = evaluate, row_tag, namespace
102
+ self.rows: list[list[str | None]] = []
103
+ self.depth = 0 # ponytail: row_tag elements nested in a row are content, not rows (Rust yields both)
104
+
105
+ def start(self, tag: str, attrib: dict[str, str]) -> ET.Element:
106
+ self.depth += self.depth > 0
107
+ element = self.builder.start(tag, attrib)
108
+ if self.depth == 0 and _matches(element, self.row_tag, self.namespace):
109
+ self.depth = 1
110
+ return element
111
+
112
+ def end(self, tag: str) -> ET.Element:
113
+ element = self.builder.end(tag)
114
+ if self.depth == 1 and _matches(element, self.row_tag, self.namespace):
115
+ self.rows.append(self.evaluate(element))
116
+ element.clear()
117
+ # ponytail: the emptied row stays in its parent; drop it there if huge files need it
118
+ self.depth -= self.depth > 0
119
+ return element
120
+
121
+ def data(self, data: str) -> None:
122
+ self.builder.data(data)
123
+
124
+ def close(self) -> ET.Element:
125
+ return self.builder.close()
126
+
127
+ def doctype(self, name: str, pubid: str | None, system: str | None) -> None:
128
+ raise ET.ParseError("DTDs and external entities are not supported")
129
+
130
+
131
+ def _options(options: SourceOptions) -> tuple[str, str | None, str | None]:
132
+ row_tag = (options.row_tag or "").strip()
133
+ if not row_tag:
134
+ raise FloeConfigError("source.options.row_tag is required for xml input")
135
+ return (
136
+ row_tag,
137
+ (options.namespace or "").strip() or None,
138
+ (options.value_tag or "").strip() or None,
139
+ )
140
+
141
+
142
+ def read_xml(
143
+ path: Path,
144
+ options: SourceOptions,
145
+ columns: Sequence[ColumnConfig],
146
+ chunk_rows: int = 1_000_000,
147
+ ) -> Iterator[XmlChunk]:
148
+ """Yield the rows under `row_tag` in chunks of at most `chunk_rows`, columns named by selector.
149
+
150
+ Missing elements and attributes, and empty text, are null. A file without rows is an error.
151
+ """
152
+ row_tag, namespace, value_tag = _options(options)
153
+ names = [c.source if c.source is not None else c.name for c in columns]
154
+ if len(set(names)) != len(names):
155
+ dup = next(n for n in names if names.count(n) > 1)
156
+ raise FloeIoError(f"duplicate xml selector source: {dup}")
157
+ plans: list[Selector] = []
158
+ for name in names:
159
+ try:
160
+ plans.append(parse_selector(name))
161
+ except ValueError as err:
162
+ raise FloeIoError(f"invalid selector {name}: {err}") from err
163
+
164
+ target = _Rows(
165
+ lambda row: [_evaluate(row, p, namespace, value_tag) for p in plans],
166
+ row_tag,
167
+ namespace,
168
+ )
169
+ parser = ET.XMLParser(target=target)
170
+ done = 0
171
+
172
+ def frame() -> pl.DataFrame:
173
+ cols = zip(*target.rows, strict=True) if target.rows else [() for _ in names]
174
+ return pl.DataFrame(
175
+ {n: pl.Series(n, list(c), dtype=pl.String) for n, c in zip(names, cols)}
176
+ )
177
+
178
+ try:
179
+ with path.open("rb") as handle:
180
+ while block := handle.read(_READ_SIZE):
181
+ parser.feed(block)
182
+ while len(target.rows) >= chunk_rows:
183
+ rest = target.rows[chunk_rows:]
184
+ target.rows = target.rows[:chunk_rows]
185
+ yield XmlChunk(done, frame())
186
+ done += chunk_rows
187
+ target.rows = rest
188
+ parser.close()
189
+ except ET.ParseError as err:
190
+ raise FloeIoError(f"xml parse error: {err}") from err
191
+ except OSError as err:
192
+ raise FloeIoError(f"failed to read xml at {path}: {err}") from err
193
+ if target.rows:
194
+ yield XmlChunk(done, frame())
195
+ elif done == 0:
196
+ raise FloeIoError(f"row_tag={row_tag} produced no rows")
197
+
198
+
199
+ def _in_namespace(tag: str, namespace: str | None) -> bool:
200
+ return namespace is None or tag[1:].partition("}")[0] == namespace
201
+
202
+
203
+ def xml_columns(path: Path, options: SourceOptions) -> list[str]:
204
+ """Sorted child element names of the first row: the probe Rust's `read_input_columns` runs."""
205
+ row_tag, namespace, _ = _options(options)
206
+ try:
207
+ row = None # the first matching element in document order, read once it closes
208
+ for event, element in ET.iterparse(path, events=("start", "end")):
209
+ if (
210
+ row is None
211
+ and event == "start"
212
+ and _matches(element, row_tag, namespace)
213
+ ):
214
+ row = element
215
+ elif element is row and event == "end":
216
+ return sorted(
217
+ {
218
+ c.tag.rpartition("}")[2]
219
+ for c in element
220
+ if _in_namespace(c.tag, namespace)
221
+ }
222
+ )
223
+ except ET.ParseError as err:
224
+ raise FloeIoError(f"xml parse error: {err}") from err
225
+ except OSError as err:
226
+ raise FloeIoError(f"failed to read xml at {path}: {err}") from err
227
+ raise FloeIoError(f"row_tag={row_tag} not found in xml")