floe-python 0.8.2__py3-none-any.whl → 0.8.3__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/_cli.py +2 -2
- floe/config/profile.py +14 -0
- floe/iceberg/catalog.py +12 -2
- floe/manifest.py +5 -2
- floe/storage/local.py +1 -0
- floe/write/outputs.py +49 -19
- {floe_python-0.8.2.dist-info → floe_python-0.8.3.dist-info}/METADATA +1 -1
- {floe_python-0.8.2.dist-info → floe_python-0.8.3.dist-info}/RECORD +11 -11
- {floe_python-0.8.2.dist-info → floe_python-0.8.3.dist-info}/WHEEL +0 -0
- {floe_python-0.8.2.dist-info → floe_python-0.8.3.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.2.dist-info → floe_python-0.8.3.dist-info}/licenses/LICENSE +0 -0
floe/_cli.py
CHANGED
|
@@ -40,7 +40,7 @@ def _realpath(path: str) -> Path:
|
|
|
40
40
|
def _profile(location: str | None) -> ProfileConfig | None:
|
|
41
41
|
if not location:
|
|
42
42
|
return None
|
|
43
|
-
return parse_profile(location if location.startswith("s3://") else _realpath(location)) # fmt: skip
|
|
43
|
+
return parse_profile(location if location.startswith(("s3://", "env:")) else _realpath(location)) # fmt: skip
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def _load(path: Path, profile: ProfileConfig | None) -> RootConfig:
|
|
@@ -83,7 +83,7 @@ def _validate(args: argparse.Namespace) -> int:
|
|
|
83
83
|
print(f" Severity: {e.policy.severity}")
|
|
84
84
|
profile_arg = ""
|
|
85
85
|
if args.profile:
|
|
86
|
-
profile_arg = f" -p {args.profile if args.profile.startswith('s3://') else os.path.realpath(args.profile)}" # fmt: skip
|
|
86
|
+
profile_arg = f" -p {args.profile if args.profile.startswith(('s3://', 'env:')) else os.path.realpath(args.profile)}" # fmt: skip
|
|
87
87
|
print(f"Next: floe run -c {path}{profile_arg}")
|
|
88
88
|
return 0
|
|
89
89
|
|
floe/config/profile.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from collections.abc import Callable
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from pathlib import Path
|
|
@@ -404,7 +405,20 @@ def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
|
|
|
404
405
|
return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
|
|
405
406
|
|
|
406
407
|
|
|
408
|
+
ENV_PREFIX = "env:"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def read_env_profile(location: str) -> str:
|
|
412
|
+
"""`env:NAME`: the profile YAML held in environment variable `NAME`."""
|
|
413
|
+
name = location.removeprefix(ENV_PREFIX)
|
|
414
|
+
if not (text := os.environ.get(name, "")).strip():
|
|
415
|
+
raise FloeConfigError(f"profile environment variable {name} is unset or empty")
|
|
416
|
+
return text
|
|
417
|
+
|
|
418
|
+
|
|
407
419
|
def _docs(location: str | Path) -> list[object]:
|
|
420
|
+
if isinstance(location, str) and location.startswith(ENV_PREFIX):
|
|
421
|
+
return parse_yaml(read_env_profile(location))
|
|
408
422
|
if isinstance(location, Path) or not location.startswith("s3://"):
|
|
409
423
|
return load_yaml(Path(location))
|
|
410
424
|
try:
|
floe/iceberg/catalog.py
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import weakref
|
|
5
8
|
from collections.abc import Generator, Sequence
|
|
6
9
|
from contextlib import contextmanager
|
|
7
10
|
from urllib.parse import urlsplit
|
|
@@ -26,6 +29,11 @@ _TRANSFORMS = frozenset({"identity", "year", "month", "day", "hour"})
|
|
|
26
29
|
# first id of a partition field, as in the Iceberg spec
|
|
27
30
|
_PARTITION_FIELD_ID_START = 1000
|
|
28
31
|
DUCKDB_CATALOG = "floe_catalog"
|
|
32
|
+
# the ADR's limits (python-core-for-olf.md, "Supported limits"); DuckDB's own default is 80% of the
|
|
33
|
+
# host's RAM, not the cgroup limit, so a Kubernetes pod is OOM-killed instead of spilling.
|
|
34
|
+
# Size pods at the memory limit + 2 GiB. The spill bound fails a runaway merge before it fills the disk.
|
|
35
|
+
DUCKDB_MEMORY_LIMIT = "4GB"
|
|
36
|
+
DUCKDB_MAX_TEMP_DIRECTORY_SIZE = "50GB"
|
|
29
37
|
|
|
30
38
|
|
|
31
39
|
def transform_name(field: PartitionField) -> str:
|
|
@@ -174,8 +182,10 @@ def open_duckdb(
|
|
|
174
182
|
raise FloeConfigError(f"iceberg catalog {definition.name}: merges and compaction require a rest catalog (got {definition.type})") # fmt: skip
|
|
175
183
|
statements = _duckdb_setup(definition, storage)
|
|
176
184
|
with run_error("duckdb catalog attach failed"):
|
|
177
|
-
|
|
178
|
-
con = duckdb.connect()
|
|
185
|
+
spill = tempfile.mkdtemp(prefix="floe-duckdb-")
|
|
186
|
+
con = duckdb.connect(config={"memory_limit": DUCKDB_MEMORY_LIMIT, "temp_directory": spill, "max_temp_directory_size": DUCKDB_MAX_TEMP_DIRECTORY_SIZE}) # fmt: skip
|
|
187
|
+
# the spill directory lives as long as the connection (also on `with` exit and at interpreter exit)
|
|
188
|
+
weakref.finalize(con, shutil.rmtree, spill, True)
|
|
179
189
|
for statement in statements:
|
|
180
190
|
con.execute(statement)
|
|
181
191
|
return con
|
floe/manifest.py
CHANGED
|
@@ -17,7 +17,7 @@ from typing import Any, NamedTuple, cast
|
|
|
17
17
|
from pydantic import BaseModel
|
|
18
18
|
|
|
19
19
|
from floe import __version__
|
|
20
|
-
from floe.config.profile import ProfileConfig
|
|
20
|
+
from floe.config.profile import ENV_PREFIX, ProfileConfig, read_env_profile
|
|
21
21
|
from floe.config.project import ReportConfig, RootConfig
|
|
22
22
|
from floe.config.storage import StoragesConfig
|
|
23
23
|
from floe.config.types import ArchiveTarget, EntityConfig, SinkTarget, SourceConfig
|
|
@@ -464,7 +464,10 @@ def build_manifest(
|
|
|
464
464
|
profile_uri = profile_checksum = None
|
|
465
465
|
if options.profile_arg is not None: # recorded like `config_uri` and its checksum
|
|
466
466
|
# an s3 profile is read from there by the run, so it is recorded as typed
|
|
467
|
-
if options.profile_arg.startswith(
|
|
467
|
+
if options.profile_arg.startswith(ENV_PREFIX): # the Job reads the variable too
|
|
468
|
+
profile_uri = options.profile_arg
|
|
469
|
+
profile_checksum = _sha256(read_env_profile(profile_uri).encode())
|
|
470
|
+
elif options.profile_arg.startswith("s3://"):
|
|
468
471
|
profile_uri = options.profile_arg
|
|
469
472
|
profile_checksum = _sha256(read_s3(profile_uri, "profile"))
|
|
470
473
|
else:
|
floe/storage/local.py
CHANGED
|
@@ -101,6 +101,7 @@ class LocalClient:
|
|
|
101
101
|
try:
|
|
102
102
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
103
103
|
mode = src.stat().st_mode & 0o777
|
|
104
|
+
dst.unlink(missing_ok=True) # not through a symlink or shared inode
|
|
104
105
|
fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
105
106
|
os.fchmod(fd, mode) # #779: the open mode applies on create only
|
|
106
107
|
with src.open("rb") as fin, os.fdopen(fd, "wb") as fout:
|
floe/write/outputs.py
CHANGED
|
@@ -4,10 +4,9 @@ import io
|
|
|
4
4
|
import math
|
|
5
5
|
import re
|
|
6
6
|
import uuid
|
|
7
|
-
from collections.abc import
|
|
8
|
-
from contextlib import contextmanager
|
|
7
|
+
from collections.abc import Callable, Generator
|
|
8
|
+
from contextlib import contextmanager, suppress
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from itertools import count
|
|
11
10
|
|
|
12
11
|
import polars as pl
|
|
13
12
|
|
|
@@ -58,15 +57,46 @@ def part_uris(client: StorageClient, base: str, ext: str) -> list[str]:
|
|
|
58
57
|
return sorted(u for u in uris if u.rpartition("/")[0] == base and u.endswith(f".{ext}") and _PART.fullmatch(u.rpartition("/")[2].removesuffix(f".{ext}"))) # fmt: skip
|
|
59
58
|
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
@contextmanager
|
|
61
|
+
def _parts(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Generator[Callable[[bytes], str]]: # fmt: skip
|
|
62
|
+
"""Yield `put(body) -> name`. Non-overwrite modes write `part-<uuid>` directly.
|
|
63
|
+
|
|
64
|
+
Overwrite stages every part as `_staging-<id>-part-NNNNN` (invisible to `part_uris`) and
|
|
65
|
+
only when all were written copies them over `part-NNNNN`, deletes the old parts that no new
|
|
66
|
+
part replaced, then the staged copies (#1054). A failed write leaves the old parts untouched.
|
|
67
|
+
"""
|
|
63
68
|
if mode != "overwrite":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
|
|
70
|
+
def put(body: bytes) -> str:
|
|
71
|
+
name = f"part-{uuid.uuid4()}.{ext}"
|
|
72
|
+
client.write(f"{base}/{name}", body)
|
|
73
|
+
return name
|
|
74
|
+
|
|
75
|
+
yield put
|
|
76
|
+
return
|
|
77
|
+
stage = f"_staging-{uuid.uuid4()}-"
|
|
78
|
+
names: list[str] = []
|
|
79
|
+
|
|
80
|
+
def put_staged(body: bytes) -> str:
|
|
81
|
+
name = f"part-{len(names):05}.{ext}"
|
|
82
|
+
names.append(name) # before the write: a partial file needs cleanup too
|
|
83
|
+
client.write(f"{base}/{stage}{name}", body)
|
|
84
|
+
return name
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
yield put_staged
|
|
88
|
+
except BaseException:
|
|
89
|
+
for name in names:
|
|
90
|
+
with suppress(FloeError, OSError): # best effort: the original error wins
|
|
91
|
+
client.delete(f"{base}/{stage}{name}")
|
|
92
|
+
raise
|
|
93
|
+
old = part_uris(client, base, ext)
|
|
94
|
+
for name in names:
|
|
95
|
+
client.copy(f"{base}/{stage}{name}", f"{base}/{name}")
|
|
96
|
+
for uri in set(old) - {f"{base}/{n}" for n in names}:
|
|
67
97
|
client.delete(uri)
|
|
68
|
-
for
|
|
69
|
-
|
|
98
|
+
for name in names:
|
|
99
|
+
client.delete(f"{base}/{stage}{name}")
|
|
70
100
|
|
|
71
101
|
|
|
72
102
|
def write_accepted(
|
|
@@ -78,7 +108,6 @@ def write_accepted(
|
|
|
78
108
|
) -> AcceptedWrite:
|
|
79
109
|
"""Write `df` as `part-*.parquet` files under `base`, split so each holds about `max_size_per_file`."""
|
|
80
110
|
base = base.rstrip("/")
|
|
81
|
-
names = _part_names(client, base, mode, "parquet")
|
|
82
111
|
max_size = (options and options.max_size_per_file) or DEFAULT_MAX_SIZE_PER_FILE
|
|
83
112
|
avg_row = max(1, math.ceil(df.estimated_size() / df.height)) if df.height else 1
|
|
84
113
|
rows = max(1, max_size // avg_row)
|
|
@@ -89,14 +118,12 @@ def write_accepted(
|
|
|
89
118
|
kwargs["row_group_size"] = options.row_group_size
|
|
90
119
|
files: list[str] = []
|
|
91
120
|
sizes: list[int] = []
|
|
92
|
-
with _os_errors():
|
|
121
|
+
with _os_errors(), _parts(client, base, mode, "parquet") as put:
|
|
93
122
|
for chunk in [df.slice(i, rows) for i in range(0, df.height, rows)] or [df]:
|
|
94
|
-
name = next(names)
|
|
95
123
|
# ponytail: a part is held in memory (≤ max_size_per_file) before the put; stream multipart uploads if that is too much
|
|
96
124
|
body = io.BytesIO()
|
|
97
125
|
chunk.write_parquet(body, **kwargs) # pyright: ignore[reportArgumentType] # ponytail: kwargs loses the compression Literal; validated by the config
|
|
98
|
-
|
|
99
|
-
files.append(name)
|
|
126
|
+
files.append(put(body.getvalue()))
|
|
100
127
|
sizes.append(body.tell())
|
|
101
128
|
total = sum(sizes)
|
|
102
129
|
return AcceptedWrite(
|
|
@@ -115,7 +142,10 @@ def write_rejected(df: pl.DataFrame, client: StorageClient, base: str, mode: Wri
|
|
|
115
142
|
"""
|
|
116
143
|
with _os_errors():
|
|
117
144
|
base = base.rstrip("/")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
if key and mode != "overwrite":
|
|
146
|
+
name = f"part-{uuid.uuid5(uuid.NAMESPACE_URL, key)}.csv"
|
|
147
|
+
client.write(f"{base}/{name}", df.write_csv().encode())
|
|
148
|
+
else:
|
|
149
|
+
with _parts(client, base, mode, "csv") as put:
|
|
150
|
+
name = put(df.write_csv().encode())
|
|
151
|
+
return f"{base}/{name}"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
floe/__init__.py,sha256=0X9pm8uWiwoDslKZyyjsEAYd6g1mlvIWhkWgE5uhGng,13279
|
|
2
2
|
floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
|
|
3
|
-
floe/_cli.py,sha256=
|
|
3
|
+
floe/_cli.py,sha256=hwast3uMX0iUtkIpcChSIVHYkoYi4fR4jwCwQT53lXE,14101
|
|
4
4
|
floe/_cli_output.py,sha256=VLxWQr-EPtIwklo9xr2CnjMD17U-7JrMUWdTtAP3jrY,8809
|
|
5
5
|
floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
|
|
6
6
|
floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
|
|
7
7
|
floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
|
|
8
8
|
floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
|
|
9
|
-
floe/manifest.py,sha256=
|
|
9
|
+
floe/manifest.py,sha256=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
|
|
10
10
|
floe/manifest_run.py,sha256=1XLInkpaCofMezMBs_5uIFoR5W7lbwCBFZeqRcnwUAw,12398
|
|
11
11
|
floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
|
|
12
12
|
floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -20,14 +20,14 @@ floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
20
20
|
floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
|
|
21
21
|
floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
|
|
22
22
|
floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
|
|
23
|
-
floe/config/profile.py,sha256=
|
|
23
|
+
floe/config/profile.py,sha256=jWackztS-0H3dTMXHALTZ7LsIOmwjriwoeY4vFgARr0,16046
|
|
24
24
|
floe/config/project.py,sha256=zgTOhTDlbMr1chViMEZyv0drozZdumTaIoKDL0vv9sw,14141
|
|
25
25
|
floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
|
|
26
26
|
floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
|
|
27
27
|
floe/config/validate.py,sha256=-cDn9CKfR1BmAATgYy1PWlTWSkpu4mjt4PMKNL7k2FE,35858
|
|
28
28
|
floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
|
|
29
29
|
floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
|
|
30
|
-
floe/iceberg/catalog.py,sha256=
|
|
30
|
+
floe/iceberg/catalog.py,sha256=QW8L6DKVr4Ug0CbuEP4wgy7Lh5-TsoOXRZWQxfM5zSQ,15115
|
|
31
31
|
floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
33
|
floe/iceberg/sink.py,sha256=uE0jLhhxRFOeKi7ChAzstKexy1ZrovE4EKATGTYn-Gk,11255
|
|
@@ -51,16 +51,16 @@ floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
|
|
|
51
51
|
floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
52
|
floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
|
|
53
53
|
floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
|
|
54
|
-
floe/storage/local.py,sha256=
|
|
54
|
+
floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
|
|
55
55
|
floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
|
|
56
56
|
floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
|
|
57
57
|
floe/storage/resolve.py,sha256=pFAuVytTfnSEcTAVlJjFTj7S_Vipjry1ormWZP3Egmc,2593
|
|
58
58
|
floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
|
|
59
59
|
floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
|
|
60
60
|
floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
61
|
-
floe/write/outputs.py,sha256=
|
|
62
|
-
floe_python-0.8.
|
|
63
|
-
floe_python-0.8.
|
|
64
|
-
floe_python-0.8.
|
|
65
|
-
floe_python-0.8.
|
|
66
|
-
floe_python-0.8.
|
|
61
|
+
floe/write/outputs.py,sha256=mPS3W2bHgeNR_FJ3Ghb13gMgiPpk5-mpSad1bwq97Kg,6034
|
|
62
|
+
floe_python-0.8.3.dist-info/METADATA,sha256=DApXCIMAHZD6G1V_b9v5T2APwgUeKUEfWYmUk10EsK4,442
|
|
63
|
+
floe_python-0.8.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
64
|
+
floe_python-0.8.3.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
65
|
+
floe_python-0.8.3.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
66
|
+
floe_python-0.8.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|