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 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
- # ponytail: DuckDB's default memory_limit and spill dir; set the ADR's limits when the run wires merges
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("s3://"):
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 Generator, Iterator
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
- def _part_names(client: StorageClient, base: str, mode: WriteMode, ext: str) -> Iterator[str]: # fmt: skip
62
- """Overwrite clears the old parts and numbers from 0 (`part-00000`); other modes use `part-<uuid>`."""
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
- while True:
65
- yield f"part-{uuid.uuid4()}.{ext}"
66
- for uri in part_uris(client, base, ext):
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 i in count():
69
- yield f"part-{i:05}.{ext}"
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
- client.write(f"{base}/{name}", body.getvalue())
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
- name = f"part-{uuid.uuid5(uuid.NAMESPACE_URL, key)}.csv" if key and mode != "overwrite" else next(_part_names(client, base, mode, "csv")) # fmt: skip
119
- uri = f"{base}/{name}"
120
- client.write(uri, df.write_csv().encode())
121
- return uri
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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -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=6zQRRczAgARgc7PcLrWS7lEzJa6GKsbpf8Zz8GsDjFo,14081
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=GDaa01K9n5vxI0kRQSkM_xjjIwoeF6JN-N1Fktas8Xc,20216
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=mYbyFse2pBUFuV5TMtl0RFrfyPUnJhdklbAX5eSAM88,15564
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=TeINK-jVZJAurROEeovVNK3s4PY-czeWih1y_KWohP8,14455
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=NKjBf_-PkDnbZ3FzXWE7KZ1xY0G_o3tRy0_7nxW7FLs,6307
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=5LgN_8PMPPOgkRVZw_KvxYXmc02-meL7iZLeMKVtufQ,4932
62
- floe_python-0.8.2.dist-info/METADATA,sha256=O9qNqOIcnbGRrkwUAcPGKh35ayNq0bG4YwZMpQ-z90g,442
63
- floe_python-0.8.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
64
- floe_python-0.8.2.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
65
- floe_python-0.8.2.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
66
- floe_python-0.8.2.dist-info/RECORD,,
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,,