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