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/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")
|