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/manifest_run.py ADDED
@@ -0,0 +1,299 @@
1
+ """Rebuild the `RootConfig` a run needs from a manifest (Rust: `manifest/reconstruct.rs`).
2
+
3
+ The manifest was validated and resolved when it was generated: nothing is re-validated here. As in
4
+ Rust, a malformed option block is dropped and an unknown enum value falls back to its default.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar, cast, get_args
12
+
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from floe.config.project import (
16
+ LineageConfig,
17
+ ProjectMetadata,
18
+ ReportConfig,
19
+ RootConfig,
20
+ config_version,
21
+ )
22
+ from floe.config.storage import CatalogsConfig, StorageDefinition, StoragesConfig
23
+ from floe.config.types import (
24
+ EntityConfig,
25
+ IcebergPartitionField,
26
+ IcebergSinkOptions,
27
+ MergeOptions,
28
+ NormalizeColumnsConfig,
29
+ PiiConfig,
30
+ SchemaEvolutionConfig,
31
+ SchemaMismatchConfig,
32
+ SinkOptions,
33
+ SinkTarget,
34
+ SourceConfig,
35
+ SourceOptions,
36
+ SourcePartitioning,
37
+ )
38
+ from floe.config.validate import FLOE_VERSION
39
+ from floe.errors import FloeConfigError, FloeIoError
40
+ from floe.storage.paths import normalize_local_path, parse_s3_uri
41
+ from floe.storage.s3 import S3Client
42
+
43
+ Json = dict[str, Any]
44
+ _M = TypeVar("_M", bound=BaseModel)
45
+ _CLOUD = ("s3://", "gs://", "gcs://", "az://", "abfss://", "abfs://")
46
+ _WRITE_MODES = ("overwrite", "append", "merge_scd1", "merge_scd2")
47
+
48
+
49
+ def _omitted_as_none(model: type[BaseModel], value: object) -> object:
50
+ """Rust's `Option`: an omitted nullable field is `None`, in nested blocks too."""
51
+ if not isinstance(value, dict):
52
+ return value
53
+ filled = dict(cast(Json, value))
54
+ for name, field in model.model_fields.items():
55
+ args = get_args(field.annotation)
56
+ nested = next((a for a in args if isinstance(a, type) and issubclass(a, BaseModel)), None) # fmt: skip
57
+ if name in filled and nested:
58
+ filled[name] = _omitted_as_none(nested, filled[name])
59
+ elif name not in filled and field.is_required() and type(None) in args:
60
+ filled[name] = None
61
+ return filled
62
+
63
+
64
+ def _lenient(model: type[_M], value: object) -> _M | None:
65
+ """`serde_json::from_value(v).ok()`."""
66
+ try:
67
+ return (
68
+ None
69
+ if value is None
70
+ else model.model_validate(_omitted_as_none(model, value))
71
+ )
72
+ except ValidationError:
73
+ return None
74
+
75
+
76
+ def _pick(value: str | None, allowed: tuple[str, ...], default: str) -> str:
77
+ return value if value in allowed else default
78
+
79
+
80
+ def _storage(name: str, named_local: bool) -> str | None:
81
+ """`local` is the implicit storage, unless a definition is named so (Rust drops it either way)."""
82
+ return None if name == "local" and not named_local else name
83
+
84
+
85
+ def _sink(t: Json, write_mode: str, named_local: bool) -> Json:
86
+ return {
87
+ "format": t["format"],
88
+ "path": t["path"],
89
+ "storage": _storage(t["storage"], named_local),
90
+ "options": _lenient(SinkOptions, t.get("options")),
91
+ "merge": _lenient(MergeOptions, t.get("merge")),
92
+ "iceberg": _lenient(IcebergSinkOptions, t.get("iceberg")),
93
+ "partition_by": t.get("partition_by"),
94
+ # #911: validated when generated, so not lenient; `num_buckets` may be omitted
95
+ "partition_spec": (spec := t.get("partition_spec"))
96
+ and [_omitted_as_none(IcebergPartitionField, f) for f in spec],
97
+ "write_mode": _pick(t.get("write_mode"), _WRITE_MODES, write_mode),
98
+ }
99
+
100
+
101
+ def _schema(s: Json) -> Json:
102
+ keys = ("source", "nullable", "unique", "width", "trim")
103
+ return {
104
+ "columns": [
105
+ {"name": c["name"], "column_type": c["column_type"]}
106
+ | {k: c.get(k) for k in keys}
107
+ for c in s["columns"]
108
+ ],
109
+ "primary_key": s["primary_key"] or None,
110
+ "unique_keys": s["unique_keys"] or None,
111
+ "normalize_columns": _lenient(
112
+ NormalizeColumnsConfig, s.get("normalize_columns")
113
+ ),
114
+ "mismatch": _lenient(SchemaMismatchConfig, s.get("mismatch")),
115
+ "schema_evolution": _lenient(SchemaEvolutionConfig, s.get("schema_evolution")),
116
+ }
117
+
118
+
119
+ def _entity(m: Json, named_local: bool) -> EntityConfig:
120
+ src, sinks = m["source"], m["sinks"]
121
+ write_mode = _pick(m.get("write_mode"), _WRITE_MODES, "overwrite")
122
+ rejected, archive = sinks.get("rejected"), sinks.get("archive")
123
+ source = {
124
+ "format": src["format"],
125
+ "path": src["path"],
126
+ "storage": _storage(src["storage"], named_local),
127
+ "options": _lenient(SourceOptions, src.get("options")) or SourceOptions(),
128
+ "cast_mode": src.get("cast_mode") or "strict",
129
+ "source_ref": src.get("ref"),
130
+ "resource": src.get("resource"),
131
+ "partitioning": _omitted_as_none(
132
+ SourcePartitioning, src.get("partitioning")
133
+ ), # D28: validated when generated, so not lenient
134
+ }
135
+ sink = {
136
+ "write_mode": write_mode,
137
+ "accepted": _sink(sinks["accepted"], write_mode, named_local),
138
+ "rejected": rejected and _sink(rejected, write_mode, named_local),
139
+ "archive": archive
140
+ and {
141
+ "path": archive["path"],
142
+ "storage": _storage(archive["storage"], named_local),
143
+ },
144
+ }
145
+ incremental = _pick(m.get("incremental_mode"), ("archive", "file", "row"), "none")
146
+ return EntityConfig.model_validate(
147
+ {
148
+ "id": m["name"],
149
+ "name": (m.get("asset_key") or [m["name"]])[-1],
150
+ "metadata": None,
151
+ "domain": m.get("domain"),
152
+ "incremental_mode": incremental,
153
+ "state": {"path": m["state_path"]} if m.get("state_path") else None,
154
+ "source": source,
155
+ "sink": sink,
156
+ "policy": {
157
+ "severity": _pick(m.get("policy_severity"), ("reject", "abort"), "warn")
158
+ },
159
+ "schema": _schema(m["schema"]),
160
+ "pii": _lenient(PiiConfig, m.get("pii")),
161
+ }
162
+ )
163
+
164
+
165
+ def _section(
166
+ model: type[_M], m: Json, name: str, shape: Callable[[Json], Json] | None = None
167
+ ) -> _M | None:
168
+ """An embedded block, back in config shape; a malformed one is an error, as in Rust."""
169
+ value = m.get(name)
170
+ try:
171
+ if value is not None and shape:
172
+ value = value | {"definitions": [shape(d) for d in value["definitions"]]}
173
+ return None if value is None else model.model_validate(value)
174
+ except (ValidationError, TypeError, KeyError, AttributeError) as e:
175
+ raise FloeConfigError(f"manifest {name} block is malformed: {e}") from e
176
+
177
+
178
+ def _storage_def(d: Json) -> Json:
179
+ return {k: v for k, v in d.items() if k != "fs_type"} | {"type": d["fs_type"]}
180
+
181
+
182
+ def _catalog_def(d: Json) -> Json:
183
+ rest = {k: v for k, v in d["type_config"].items() if k != "type"} # D1: REST only
184
+ return {k: v for k, v in d.items() if k != "type_config"} | rest
185
+
186
+
187
+ def _report(base: str, storages: StoragesConfig | None) -> tuple[ReportConfig, StoragesConfig | None]: # fmt: skip
188
+ """Rust `RunContext::from_config`: an `s3://` base uses the storage on its bucket, else a synthetic one."""
189
+ if not base.startswith("s3://"):
190
+ local = storages and next((d.name for d in storages.definitions if d.type == "local"), None) # fmt: skip
191
+ return ReportConfig(path=base.removeprefix("local://"), formatter=None, storage=local), storages # fmt: skip
192
+ bucket = parse_s3_uri(base)[0]
193
+ defs = storages.definitions if storages else []
194
+ found = next((d.name for d in defs if d.type == "s3" and d.bucket == bucket), None) # fmt: skip
195
+ # the report storage never becomes the default: unnamed paths stay local
196
+ if found is None:
197
+ found = "__report_s3__"
198
+ none = dict.fromkeys(("region", "account", "container", "prefix", "endpoint", "path_style_access")) # fmt: skip
199
+ synthetic = StorageDefinition(name=found, type="s3", bucket=bucket, **none)
200
+ local = [] if storages else [StorageDefinition(name="local", type="local", bucket=None, **none)] # fmt: skip
201
+ storages = StoragesConfig(default=storages and storages.default, definitions=[*defs, *local, synthetic]) # fmt: skip
202
+ return ReportConfig(path=base, formatter=None, storage=found), storages
203
+
204
+
205
+ def _cloud(target: _M, uri: str | None) -> _M:
206
+ """A cloud `uri` replaces the path, so lineage names the dataset by its bucket."""
207
+ is_cloud = uri is not None and uri.startswith(_CLOUD)
208
+ return target.model_copy(update={"path": uri}) if is_cloud else target
209
+
210
+
211
+ def _lineage_entity(entity: EntityConfig, m: Json) -> EntityConfig:
212
+ """Rust `lineage_inputs_from_manifest_json`."""
213
+ sink, sinks = entity.sink, m["sinks"]
214
+ accepted: SinkTarget = _cloud(sink.accepted, sinks["accepted"].get("uri"))
215
+ rejected = sink.rejected and _cloud(sink.rejected, sinks["rejected"].get("uri"))
216
+ source: SourceConfig = _cloud(entity.source, m["source"]["uri"])
217
+ update = {"accepted": accepted, "rejected": rejected}
218
+ return entity.model_copy(update={"source": source, "sink": sink.model_copy(update=update)}) # fmt: skip
219
+
220
+
221
+ def _named_local(m: Json) -> bool:
222
+ try:
223
+ return any(d["name"] == "local" for d in m["storages"]["definitions"])
224
+ except (KeyError, TypeError): # no storages; a malformed block fails in `_section`
225
+ return False
226
+
227
+
228
+ def _read(location: str) -> bytes:
229
+ # S3 settings come from the environment (AWS_ENDPOINT_URL, ...), as for `-o s3://`
230
+ if location.startswith("s3://"):
231
+ found = S3Client(parse_s3_uri(location)[0]).read(location)
232
+ if found is None:
233
+ raise FloeIoError(f"manifest not found: {location}")
234
+ return found.body
235
+ if "://" in location: # D1: gcs and adls are dropped
236
+ raise FloeConfigError(f"remote manifest is not supported: {location}")
237
+ try:
238
+ return Path(location).read_bytes()
239
+ except OSError as e: # as the CLI's `_realpath`
240
+ raise FloeIoError(f"{e.strerror} (os error {e.errno})") from e
241
+
242
+
243
+ def shown(location: str) -> str:
244
+ """The manifest as the run reports it: a remote one as typed."""
245
+ return location if "://" in location else str(normalize_local_path(Path(location)))
246
+
247
+
248
+ def _replay_base(location: str, m: Json) -> Path:
249
+ """Rust `manifest_replay_config_base`: a remote manifest resolves local paths under its work root.
250
+
251
+ A local one resolves them beside `config_uri`, where `manifest generate` resolved them (#1008).
252
+ """
253
+ if not location.startswith("s3://"):
254
+ config = m.get("config_uri")
255
+ if isinstance(config, str) and config.startswith("local:///"):
256
+ return Path(config.removeprefix("local://"))
257
+ return Path(location)
258
+ env = os.environ.get("FLOE_WORK_ROOT", "")
259
+ image = str(m.get("runtime_env") or "").lower() == "image"
260
+ root = m.get("work_root") or (env if env.strip() else None) or ("/work" if image else os.getcwd()) # fmt: skip
261
+ return Path(root) / "manifest.json" # the run resolves against its parent
262
+
263
+
264
+ def load_manifest(location: str | Path) -> tuple[RootConfig, list[EntityConfig], Path]:
265
+ """The run's config, the entities its lineage observes, and the path its relative paths resolve against."""
266
+ location = str(location)
267
+ data = _read(location)
268
+ try:
269
+ m = json.loads(data.decode())
270
+ spec = m["spec_version"]
271
+ entities = [_entity(e, _named_local(m)) for e in m["entities"]]
272
+ report_base = m["report_base_uri"]
273
+ meta = m.get("metadata") # D20: dumped without its None fields
274
+ metadata = None if meta is None else ProjectMetadata.model_validate(_omitted_as_none(ProjectMetadata, meta)) # fmt: skip
275
+ except (ValueError, KeyError, TypeError, AttributeError) as e:
276
+ why = f"missing field `{e.args[0]}`" if isinstance(e, KeyError) else e
277
+ raise FloeConfigError(f"manifest parse error: {why}") from e
278
+ try:
279
+ supported = config_version(spec) <= FLOE_VERSION
280
+ except FloeConfigError:
281
+ supported = False
282
+ if not supported:
283
+ floe = f"{FLOE_VERSION[0]}.{FLOE_VERSION[1]}"
284
+ raise FloeConfigError(f"manifest spec_version={spec} is unsupported (newer than Floe {floe})") # fmt: skip
285
+ report, storages = _report(report_base, _section(StoragesConfig, m, "storages", _storage_def)) # fmt: skip
286
+ root = RootConfig(
287
+ version=spec,
288
+ metadata=metadata,
289
+ storages=storages,
290
+ catalogs=_section(CatalogsConfig, m, "catalogs", _catalog_def),
291
+ env=None,
292
+ domains=[],
293
+ report=report,
294
+ lineage=_section(LineageConfig, m, "lineage"),
295
+ entities=entities,
296
+ config_files=[],
297
+ )
298
+ observed = [_lineage_entity(e, me) for e, me in zip(entities, m["entities"])]
299
+ return root, observed, _replay_base(location, m)
floe/pii.py ADDED
@@ -0,0 +1,134 @@
1
+ import hashlib
2
+ import hmac
3
+ import os
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ import polars as pl
10
+
11
+ from floe.errors import FloeConfigError, FloeRunError
12
+
13
+ Strategy = Literal["hash", "drop", "nullify", "redact", "mask", "tokenize"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PiiColumn:
18
+ name: str
19
+ strategy: Strategy
20
+ mask_pattern: str | None = None
21
+ redact_value: str | None = None
22
+ key: str | None = None
23
+
24
+
25
+ def apply_pii_masking(
26
+ df: pl.DataFrame,
27
+ columns: Sequence[PiiColumn],
28
+ schema_to_runtime: Mapping[str, str],
29
+ ) -> pl.DataFrame:
30
+ for col in columns:
31
+ name = schema_to_runtime.get(col.name, col.name)
32
+ if name not in df.columns:
33
+ continue
34
+ match col.strategy:
35
+ case "hash":
36
+ df = df.with_columns(_hash(_as_string(df[name]), name, col.key))
37
+ case "drop":
38
+ df = df.drop(name)
39
+ case "nullify":
40
+ df = df.with_columns(pl.lit(None, dtype=df.schema[name]).alias(name))
41
+ case "redact":
42
+ text = _as_string(df[name])
43
+ df = df.with_columns(
44
+ text.to_frame().select(
45
+ _when_set(name, pl.lit(col.redact_value or "[REDACTED]"))
46
+ )
47
+ )
48
+ case "mask":
49
+ text = _as_string(df[name])
50
+ df = df.with_columns(
51
+ text.to_frame().select(_mask(name, col.mask_pattern or ""))
52
+ )
53
+ case "tokenize":
54
+ pass # rejected at config validation; never reached at runtime
55
+ return df
56
+
57
+
58
+ def _as_string(series: pl.Series) -> pl.Series:
59
+ try:
60
+ return series.cast(pl.String)
61
+ except pl.exceptions.PolarsError as e:
62
+ raise FloeRunError(
63
+ f"PII: failed to cast column {series.name} to string: {e}"
64
+ ) from e
65
+
66
+
67
+ def _when_set(name: str, value: pl.Expr) -> pl.Expr:
68
+ return pl.when(pl.col(name).is_not_null()).then(value).alias(name)
69
+
70
+
71
+ def token_n(pattern: str, token: str) -> int:
72
+ start = pattern.find(token)
73
+ if start < 0:
74
+ return 0
75
+ rest = pattern[start + len(token) :]
76
+ end = rest.find("}")
77
+ digits = rest[:end]
78
+ if end < 0 or not re.fullmatch(r"\+?[0-9]+", digits) or int(digits) >= 2**64:
79
+ return 0
80
+ return int(digits)
81
+
82
+
83
+ def _mask(name: str, pattern: str) -> pl.Expr:
84
+ first_n = token_n(pattern, "{first")
85
+ last_n = token_n(pattern, "{last")
86
+ text = pl.col(name)
87
+ chars = text.str.len_chars()
88
+ last = pl.min_horizontal(pl.lit(last_n, pl.UInt32), chars)
89
+ first = pl.min_horizontal(pl.lit(first_n, pl.UInt32), chars - last)
90
+ out = _when_set(name, pl.lit(pattern)) # broadcast the pattern to one row per value
91
+ if first_n:
92
+ out = out.str.replace_all(
93
+ f"{{first{first_n}}}", text.str.slice(0, first), literal=True
94
+ )
95
+ if last_n:
96
+ out = out.str.replace_all(
97
+ f"{{last{last_n}}}", text.str.slice(chars - last, last), literal=True
98
+ )
99
+ return out.alias(name)
100
+
101
+
102
+ def _resolve_key(raw: str, name: str) -> str:
103
+ if "${" not in raw:
104
+ return raw
105
+ if not (raw.startswith("${") and raw.endswith("}")):
106
+ raise FloeConfigError(
107
+ f"pii column {name}: key must be a plain value or a single ${{VAR_NAME}} reference; "
108
+ "mixing literal text with ${...} is not supported"
109
+ )
110
+ var = raw[2:-1]
111
+ if not var or "{" in var or "}" in var:
112
+ raise FloeConfigError(f"pii column {name}: key has invalid placeholder syntax")
113
+ value = os.environ.get(var)
114
+ if value is None:
115
+ raise FloeRunError(
116
+ f"pii column {name}: key references env var {var} which is not set"
117
+ )
118
+ return value
119
+
120
+
121
+ def _hash(values: pl.Series, name: str, key: str | None) -> pl.Series:
122
+ # ponytail: ~0.8M rows/s single-threaded; upgrade to vectorised HMAC if it becomes a bottleneck
123
+ if key is None:
124
+ out = [
125
+ None if v is None else hashlib.sha256(v.encode()).hexdigest()
126
+ for v in values
127
+ ]
128
+ else:
129
+ raw = _resolve_key(key, name).encode()
130
+ out = [
131
+ None if v is None else hmac.digest(raw, v.encode(), "sha256").hex()
132
+ for v in values
133
+ ]
134
+ return pl.Series(name, out, dtype=pl.String)
floe/py.typed ADDED
File without changes
floe/read/__init__.py ADDED
File without changes
floe/read/avro.py ADDED
@@ -0,0 +1,40 @@
1
+ """Avro reader: native column types, in chunks (Rust: `io/read/avro.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 AvroChunk:
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_avro(
21
+ path: Path, columns: Collection[str] = (), chunk_rows: int = 1_000_000
22
+ ) -> Iterator[AvroChunk]:
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 keeps everything.
27
+ """
28
+ try:
29
+ # ponytail: Polars reads the whole file (no streaming Avro scan); chunks bound only what
30
+ # the checks hold at once. Switch to a streaming reader if Avro files outgrow memory.
31
+ df = pl.read_avro(path)
32
+ except (pl.exceptions.PolarsError, OSError) as err:
33
+ raise FloeIoError(f"avro read failed: {err}") from err
34
+ picked = [name for name in df.columns if name in columns]
35
+ if picked:
36
+ df = df.select(picked)
37
+ for offset in range(0, df.height, chunk_rows):
38
+ yield AvroChunk(offset, df.slice(offset, chunk_rows))
39
+ if df.height == 0:
40
+ yield AvroChunk(0, df)
floe/read/csv.py ADDED
@@ -0,0 +1,138 @@
1
+ """CSV reader: every column as a raw string, in bounded chunks (Rust: `io/read/csv.rs`)."""
2
+
3
+ import csv
4
+ import json
5
+ from collections.abc import Iterator, Sequence
6
+ from dataclasses import dataclass
7
+ from itertools import islice
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ import polars as pl
12
+
13
+ from floe.config.types import SourceOptions
14
+ from floe.errors import FloeConfigError, FloeIoError
15
+
16
+ RAGGED_MESSAGE = "found more fields than defined in 'Schema'"
17
+ _ENCODINGS: dict[str, Literal["utf8", "utf8-lossy"]] = {
18
+ "utf8": "utf8",
19
+ "lossyutf8": "utf8-lossy",
20
+ }
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CsvChunk:
25
+ """`offset` is the whole-file index of the chunk's first data row (add it to the row index).
26
+
27
+ `ragged` marks rows with more fields than the header (decision D3, #603): their values are
28
+ truncated and the caller rejects just those rows with `RAGGED_MESSAGE`.
29
+ """
30
+
31
+ offset: int
32
+ df: pl.DataFrame
33
+ ragged: pl.Series
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class _Args:
38
+ separator: str
39
+ encoding: Literal["utf8", "utf8-lossy"]
40
+ null_values: list[str] | None
41
+ has_header: bool
42
+
43
+ def scan(
44
+ self,
45
+ path: Path,
46
+ schema: dict[str, type[pl.String]] | None = None,
47
+ n_rows: int | None = None,
48
+ ragged_from: int | None = None,
49
+ ) -> pl.LazyFrame:
50
+ """`ragged_from`: truncate over-long rows and skip that many data rows."""
51
+ return pl.scan_csv(
52
+ path,
53
+ has_header=self.has_header,
54
+ separator=self.separator,
55
+ encoding=self.encoding,
56
+ null_values=self.null_values,
57
+ infer_schema=False,
58
+ schema=schema,
59
+ n_rows=n_rows,
60
+ truncate_ragged_lines=ragged_from is not None,
61
+ skip_rows_after_header=ragged_from or 0,
62
+ )
63
+
64
+
65
+ def _scan_args(options: SourceOptions) -> _Args:
66
+ separator = ";" if options.separator is None else options.separator
67
+ if len(separator.encode()) != 1:
68
+ raise FloeConfigError(
69
+ f"separator must be a single byte, got {json.dumps(separator, ensure_ascii=False)}"
70
+ )
71
+ raw = "utf8" if options.encoding is None else options.encoding
72
+ encoding = _ENCODINGS.get(raw.lower().replace("-", "").replace("_", ""))
73
+ if encoding is None:
74
+ raise FloeConfigError(f"unsupported encoding: {raw}")
75
+ return _Args(
76
+ separator, encoding, options.null_values or None, options.header is not False
77
+ )
78
+
79
+
80
+ def _columns(path: Path, args: _Args, declared: Sequence[str]) -> list[str]:
81
+ try:
82
+ probe = args.scan(path, n_rows=0 if args.has_header else 1).collect()
83
+ except (pl.exceptions.PolarsError, OSError) as err:
84
+ # Rust's polars prints the error kind: `no data: empty CSV`
85
+ kind = "no data: " if isinstance(err, pl.exceptions.NoDataError) else ""
86
+ raise FloeIoError(f"csv header read failed: {kind}{err}") from err
87
+ if args.has_header:
88
+ return probe.columns
89
+ names = list(declared[: probe.width])
90
+ return names + [f"extra_column_{i + 1}" for i in range(len(declared), probe.width)]
91
+
92
+
93
+ def _field_counts(path: Path, args: _Args, skip: int) -> Iterator[int]:
94
+ errors = "replace" if args.encoding == "utf8-lossy" else "strict"
95
+ with path.open(newline="", encoding="utf-8-sig", errors=errors) as handle:
96
+ rows = csv.reader(handle, delimiter=args.separator)
97
+ for _ in islice(rows, skip):
98
+ pass
99
+ for row in rows:
100
+ yield len(row)
101
+
102
+
103
+ def read_csv(
104
+ path: Path,
105
+ options: SourceOptions,
106
+ declared: Sequence[str] = (),
107
+ chunk_rows: int = 1_000_000,
108
+ ) -> Iterator[CsvChunk]:
109
+ """Yield the file in chunks of at most `chunk_rows` rows; always at least one (maybe empty).
110
+
111
+ `declared` names the headless columns, as Rust. Nulls follow `null_values` and Polars: an
112
+ unquoted empty field is always null.
113
+ """
114
+ args = _scan_args(options)
115
+ names = _columns(path, args, declared)
116
+ schema = dict.fromkeys(names, pl.String)
117
+ done = 0
118
+ try:
119
+ for df in args.scan(path, schema).collect_batches(chunk_size=chunk_rows):
120
+ yield CsvChunk(done, df, pl.Series([False] * df.height, dtype=pl.Boolean))
121
+ done += df.height
122
+ except pl.exceptions.ComputeError as err:
123
+ if RAGGED_MESSAGE not in str(err):
124
+ raise FloeIoError(f"csv read failed: {err}") from err
125
+ # ponytail: the ragged-row pass reads the file again in Python's csv module, only for files
126
+ # that have such rows; a Polars option to flag them would replace it.
127
+ counts = _field_counts(path, args, done + int(args.has_header))
128
+ for df in args.scan(path, schema, ragged_from=done).collect_batches(
129
+ chunk_size=chunk_rows
130
+ ):
131
+ mask = [n > len(names) for n in islice(counts, df.height)]
132
+ yield CsvChunk(done, df, pl.Series(mask, dtype=pl.Boolean))
133
+ done += df.height
134
+ except pl.exceptions.PolarsError as err:
135
+ raise FloeIoError(f"csv read failed: {err}") from err
136
+ if done == 0:
137
+ empty = pl.DataFrame(schema=dict.fromkeys(names, pl.String))
138
+ yield CsvChunk(0, empty, pl.Series([], dtype=pl.Boolean))