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/storage/local.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Local storage client, ported from `io/storage/providers/local.rs` (`LocalClient`)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from floe.errors import FloeStorageError
|
|
12
|
+
from floe.storage.paths import normalize_local_path
|
|
13
|
+
from floe.storage.types import ObjectRef, StoredObject
|
|
14
|
+
|
|
15
|
+
_LOCK_STALE_TTL_S = 5 * 60
|
|
16
|
+
_LOCK_TIMEOUT_S = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _path(uri: str) -> Path:
|
|
20
|
+
return Path(uri.removeprefix("local://"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version(path: Path) -> str:
|
|
24
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _lock_is_stale(lock: Path) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
acquired_ms = int(lock.read_text().split(":")[0].strip())
|
|
30
|
+
age = time.time() - acquired_ms / 1000
|
|
31
|
+
except (ValueError, OSError):
|
|
32
|
+
try:
|
|
33
|
+
age = time.time() - lock.stat().st_mtime
|
|
34
|
+
except OSError:
|
|
35
|
+
return False
|
|
36
|
+
return age > _LOCK_STALE_TTL_S
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _break_stale_lock(lock: Path) -> bool:
|
|
40
|
+
"""Single-winner break: a hard link to a per-inode claim name succeeds once."""
|
|
41
|
+
try:
|
|
42
|
+
ino = lock.stat().st_ino
|
|
43
|
+
claim = Path(f"{lock}.{ino}.742.claim")
|
|
44
|
+
os.link(lock, claim)
|
|
45
|
+
except OSError: # lock vanished, or another waiter already holds the claim
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
# the lock may have been released and retaken between the check and the link
|
|
49
|
+
if claim.stat().st_ino == ino and _lock_is_stale(claim):
|
|
50
|
+
lock.unlink(missing_ok=True)
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
finally:
|
|
54
|
+
# ponytail: a crash before this unlink leaves the claim; the lock then times out
|
|
55
|
+
claim.unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def _file_lock(base: Path) -> Generator[None]:
|
|
60
|
+
lock = Path(f"{base}.lock")
|
|
61
|
+
deadline = time.monotonic() + _LOCK_TIMEOUT_S
|
|
62
|
+
while True:
|
|
63
|
+
try:
|
|
64
|
+
fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
65
|
+
except FileExistsError:
|
|
66
|
+
if _lock_is_stale(lock) and _break_stale_lock(lock):
|
|
67
|
+
continue
|
|
68
|
+
if time.monotonic() >= deadline:
|
|
69
|
+
raise FloeStorageError(
|
|
70
|
+
f"timed out acquiring local state lock {lock}"
|
|
71
|
+
) from None
|
|
72
|
+
time.sleep(0.01)
|
|
73
|
+
continue
|
|
74
|
+
with os.fdopen(fd, "w") as f:
|
|
75
|
+
f.write(f"{time.time_ns() // 1_000_000}:{os.getpid()}")
|
|
76
|
+
break
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
lock.unlink(missing_ok=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LocalClient:
|
|
84
|
+
def resolve_uri(self, path: str) -> str:
|
|
85
|
+
return f"local://{normalize_local_path(Path.cwd() / path)}"
|
|
86
|
+
|
|
87
|
+
def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]:
|
|
88
|
+
p = Path(prefix)
|
|
89
|
+
if p.is_file():
|
|
90
|
+
return [ObjectRef(self.resolve_uri(prefix), prefix, None, None)]
|
|
91
|
+
if not p.exists():
|
|
92
|
+
return []
|
|
93
|
+
keys = [str(c) for c in p.iterdir() if c.is_file() and str(c) > start_after]
|
|
94
|
+
return sorted(
|
|
95
|
+
(ObjectRef(self.resolve_uri(k), k, None, None) for k in keys),
|
|
96
|
+
key=lambda r: r.uri,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def copy(self, src_uri: str, dst_uri: str) -> None:
|
|
100
|
+
src, dst = _path(src_uri), _path(dst_uri)
|
|
101
|
+
try:
|
|
102
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
mode = src.stat().st_mode & 0o777
|
|
104
|
+
fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
105
|
+
os.fchmod(fd, mode) # #779: the open mode applies on create only
|
|
106
|
+
with src.open("rb") as fin, os.fdopen(fd, "wb") as fout:
|
|
107
|
+
shutil.copyfileobj(fin, fout)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
raise FloeStorageError(f"local copy failed from {src} to {dst}: {e}") from e
|
|
110
|
+
|
|
111
|
+
def write(self, uri: str, body: bytes) -> None:
|
|
112
|
+
path = _path(uri)
|
|
113
|
+
try:
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path.write_bytes(body)
|
|
116
|
+
except OSError as e:
|
|
117
|
+
raise FloeStorageError(f"local write failed for {path}: {e}") from e
|
|
118
|
+
|
|
119
|
+
def delete(self, uri: str) -> None:
|
|
120
|
+
path = _path(uri)
|
|
121
|
+
try:
|
|
122
|
+
path.unlink(missing_ok=True)
|
|
123
|
+
except OSError as e:
|
|
124
|
+
raise FloeStorageError(f"local delete failed for {path}: {e}") from e
|
|
125
|
+
|
|
126
|
+
def read(self, uri: str) -> StoredObject | None:
|
|
127
|
+
path = _path(uri)
|
|
128
|
+
try:
|
|
129
|
+
if not path.exists():
|
|
130
|
+
return None
|
|
131
|
+
with _file_lock(path):
|
|
132
|
+
if not path.exists():
|
|
133
|
+
return None
|
|
134
|
+
return StoredObject(path.read_bytes(), _version(path))
|
|
135
|
+
except OSError as e:
|
|
136
|
+
raise FloeStorageError(f"local read failed for {path}: {e}") from e
|
|
137
|
+
|
|
138
|
+
def write_conditional(
|
|
139
|
+
self, uri: str, expected_version: str | None, body: bytes
|
|
140
|
+
) -> str | None:
|
|
141
|
+
path = _path(uri)
|
|
142
|
+
try:
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
with _file_lock(path):
|
|
145
|
+
current = _version(path) if path.exists() else None
|
|
146
|
+
if current != expected_version:
|
|
147
|
+
return None
|
|
148
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.742.tmp")
|
|
149
|
+
try:
|
|
150
|
+
with tmp.open("wb") as f:
|
|
151
|
+
if current is not None:
|
|
152
|
+
shutil.copymode(path, tmp)
|
|
153
|
+
f.write(body)
|
|
154
|
+
f.flush()
|
|
155
|
+
os.fsync(f.fileno())
|
|
156
|
+
os.replace(tmp, path)
|
|
157
|
+
finally:
|
|
158
|
+
tmp.unlink(missing_ok=True)
|
|
159
|
+
return _version(path)
|
|
160
|
+
except OSError as e:
|
|
161
|
+
raise FloeStorageError(f"local write failed for {path}: {e}") from e
|
|
162
|
+
|
|
163
|
+
def delete_conditional(self, uri: str, expected_version: str | None) -> bool:
|
|
164
|
+
"""False on a version conflict; `None` expects no file."""
|
|
165
|
+
path = _path(uri)
|
|
166
|
+
try:
|
|
167
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
with _file_lock(path):
|
|
169
|
+
current = _version(path) if path.exists() else None
|
|
170
|
+
if current != expected_version:
|
|
171
|
+
return False
|
|
172
|
+
path.unlink(missing_ok=True)
|
|
173
|
+
return True
|
|
174
|
+
except OSError as e:
|
|
175
|
+
raise FloeStorageError(f"local delete failed for {path}: {e}") from e
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""D28: `source.partitioning`, the date and batch of each input from its directory, and the load order."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from floe.config.types import SourcePartitioning
|
|
8
|
+
from floe.errors import FloeRunError
|
|
9
|
+
from floe.state import EntityState
|
|
10
|
+
from floe.storage.inputs import InputFile, ResolvedInputs
|
|
11
|
+
|
|
12
|
+
_TOKEN = re.compile(r"\{(yyyy|MM|dd|HH|batch)\}") # validated: no other token
|
|
13
|
+
_GROUP = {"yyyy": r"\d{4}", "MM": r"\d{2}", "dd": r"\d{2}", "HH": r"\d{2}", "batch": r"[^/]+"} # fmt: skip
|
|
14
|
+
_FRACTION = re.compile(r"(?:\.(\d+))?Z$")
|
|
15
|
+
_ARCHIVED = re.compile(r"__run-(?!.*__run-).*__src-[0-9a-f]{16}(?=(?:\.[^.]*)?$)") # D29 suffix # fmt: skip
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Partition(NamedTuple):
|
|
19
|
+
when: date | datetime # a datetime when the pattern has `{HH}`
|
|
20
|
+
batch: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def source_root(uri: str) -> str:
|
|
24
|
+
"""The directory partition paths are relative to: the source URI, cut before its first glob."""
|
|
25
|
+
cut = next((i for i, c in enumerate(uri) if c in "*?["), None)
|
|
26
|
+
return (uri if cut is None else uri[: uri.rfind("/", 0, cut)]).rstrip("/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def partition_of(p: SourcePartitioning, entity: str, root: str, uri: str) -> Partition: # fmt: skip
|
|
30
|
+
"""Match the input's directory, relative to `root`, against the whole pattern."""
|
|
31
|
+
parts = _TOKEN.split(p.pattern.strip("/"))
|
|
32
|
+
regex = "".join(re.escape(s) if i % 2 == 0 else f"(?P<{s}>{_GROUP[s]})" for i, s in enumerate(parts)) # fmt: skip
|
|
33
|
+
folder = uri.removeprefix(f"{root}/").rpartition("/")[0] if uri.startswith(f"{root}/") else None # fmt: skip
|
|
34
|
+
match = None if folder is None else re.fullmatch(regex, folder)
|
|
35
|
+
if match is None:
|
|
36
|
+
raise FloeRunError(f"entity.name={entity} input {uri} does not match source.partitioning.pattern={p.pattern}") # fmt: skip
|
|
37
|
+
hour = match.groupdict().get("HH")
|
|
38
|
+
try:
|
|
39
|
+
when = datetime(int(match["yyyy"]), int(match["MM"]), int(match["dd"]), int(hour or 0)) # fmt: skip
|
|
40
|
+
except (
|
|
41
|
+
ValueError
|
|
42
|
+
) as err: # the date as written: Python's own wording varies by version
|
|
43
|
+
written = f"{match['yyyy']}-{match['MM']}-{match['dd']}" + (f"T{hour}" if hour is not None else "") # fmt: skip
|
|
44
|
+
raise FloeRunError(f"entity.name={entity} input {uri} has an invalid source.partitioning date: {written}") from err # fmt: skip
|
|
45
|
+
return Partition(when if hour is not None else when.date(), match.groupdict().get("batch")) # fmt: skip
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mtime(file: InputFile) -> str:
|
|
49
|
+
"""RFC 3339 with nine fraction digits, so the strings sort in time order."""
|
|
50
|
+
return _FRACTION.sub(lambda m: f".{(m[1] or '').ljust(9, '0')}Z", file.source_mtime or "") # fmt: skip
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def in_load_order(p: SourcePartitioning, entity: str, root: str, inputs: ResolvedInputs, archived: bool = False, start: date | None = None) -> ResolvedInputs: # fmt: skip
|
|
54
|
+
"""By date, then batch (earliest modification time, then name), then file name; hash batches don't sort by name.
|
|
55
|
+
|
|
56
|
+
D29: `archived` inputs sort by their original name, so a full refresh replays the files in their first order.
|
|
57
|
+
D30: a file dated before `start` is dropped, whatever the listing returned.
|
|
58
|
+
"""
|
|
59
|
+
of = {f.source_uri: partition_of(p, entity, root, f.source_uri) for f in inputs.files} # fmt: skip
|
|
60
|
+
if start is not None:
|
|
61
|
+
inputs = ResolvedInputs([f for f in inputs.files if _day(of[f.source_uri].when) >= start], [], inputs.mode) # fmt: skip
|
|
62
|
+
first: dict[Partition, str] = {}
|
|
63
|
+
for f in inputs.files:
|
|
64
|
+
first[of[f.source_uri]] = min(first.get(of[f.source_uri], "~"), _mtime(f))
|
|
65
|
+
|
|
66
|
+
def key(f: InputFile) -> tuple[str, str, str, str]:
|
|
67
|
+
part = of[f.source_uri]
|
|
68
|
+
name = _ARCHIVED.sub("", f.source_name) if archived else f.source_name
|
|
69
|
+
return (part.when.isoformat(), first[part], part.batch or "", name)
|
|
70
|
+
|
|
71
|
+
files = sorted(inputs.files, key=key)
|
|
72
|
+
return ResolvedInputs(files, [f.source_uri for f in files], inputs.mode)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _day(when: date | datetime) -> date:
|
|
76
|
+
return when.date() if isinstance(when, datetime) else when
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dated(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None:
|
|
80
|
+
"""The partition date of a state entry; None for one outside the pattern (another source path)."""
|
|
81
|
+
try:
|
|
82
|
+
return _day(partition_of(p, entity, root, uri).when)
|
|
83
|
+
except FloeRunError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def window(p: SourcePartitioning, entity: str, root: str, state: EntityState | None) -> date | None: # fmt: skip
|
|
88
|
+
"""D30: the first date a file-mode run lists, the watermark minus `lookback_days`.
|
|
89
|
+
|
|
90
|
+
An outstanding claim holds it back to the claim's date, so a crashed run's files stay listed until it is retried.
|
|
91
|
+
"""
|
|
92
|
+
if state is None or state.watermark is None:
|
|
93
|
+
return None
|
|
94
|
+
start = date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)
|
|
95
|
+
return min([start, *(d for u in state.claims if (d := _dated(p, entity, root, u)))]) # fmt: skip
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def listing_start(p: SourcePartitioning, start: date) -> str | None:
|
|
99
|
+
"""D30: the pattern up to `{dd}` for the day before `start`, which `start` and every later day sort after; None when the date does not lead the pattern.
|
|
100
|
+
|
|
101
|
+
The day before, not `start`: SeaweedFS treats `StartAfter` as a directory marker and skips that whole directory.
|
|
102
|
+
"""
|
|
103
|
+
head, start = p.pattern.strip("/").partition("{dd}")[0], start - timedelta(days=1)
|
|
104
|
+
if "{batch}" in head or "{HH}" in head or not 0 <= head.find("{yyyy}") < head.find("{MM}"): # fmt: skip
|
|
105
|
+
return None
|
|
106
|
+
return head.replace("{yyyy}", f"{start.year:04d}").replace("{MM}", f"{start.month:02d}") + f"{start.day:02d}" # fmt: skip
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def advance(p: SourcePartitioning, entity: str, root: str, state: EntityState, promoted: list[str]) -> None: # fmt: skip
|
|
110
|
+
"""D30, on promote: the watermark moves to the latest date promoted, never back (#949 will prune `files`).
|
|
111
|
+
|
|
112
|
+
Without one (a first run, or after a failed full refresh), every file in the state counts.
|
|
113
|
+
"""
|
|
114
|
+
known = promoted if state.watermark else [*promoted, *state.files]
|
|
115
|
+
days = [d.isoformat() for u in known if (d := _dated(p, entity, root, u))]
|
|
116
|
+
state.watermark = max([state.watermark or "", *days]) or None
|
floe/storage/paths.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Path and URI helpers, ported from `io/storage/core/{paths,uri}.rs`."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from floe.errors import FloeConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_s3_uri(uri: str) -> tuple[str, str]:
|
|
9
|
+
if not uri.startswith("s3://"):
|
|
10
|
+
raise FloeConfigError(f"expected s3 uri, got {uri}")
|
|
11
|
+
bucket, _, key = uri.removeprefix("s3://").partition("/")
|
|
12
|
+
if not bucket:
|
|
13
|
+
raise FloeConfigError(f"missing bucket in s3 uri: {uri}")
|
|
14
|
+
return bucket, key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_local_path(path: Path) -> Path:
|
|
18
|
+
"""Collapse `.` and `..` lexically, but keep a `..` that follows a symlink."""
|
|
19
|
+
out = Path(path.anchor)
|
|
20
|
+
for part in path.parts[1 if path.anchor else 0 :]:
|
|
21
|
+
if part != "..":
|
|
22
|
+
out /= part
|
|
23
|
+
elif out.name not in ("", "..") and not out.is_symlink():
|
|
24
|
+
out = out.parent
|
|
25
|
+
elif not (path.is_absolute() and out == Path(out.anchor)):
|
|
26
|
+
out /= ".."
|
|
27
|
+
if out == Path():
|
|
28
|
+
return Path(path.anchor or ".")
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_MAX_FILENAME_COMPONENT_BYTES = 255
|
|
33
|
+
_MAX_ARCHIVE_RUN_COMPONENT_BYTES = 48
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fnv1a_hex(value: str) -> str:
|
|
37
|
+
h = 0xCBF29CE484222325
|
|
38
|
+
for byte in value.encode():
|
|
39
|
+
h = ((h ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
|
40
|
+
return f"{h:016x}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _truncate_utf8(value: str, max_bytes: int) -> str:
|
|
44
|
+
return value.encode()[:max_bytes].decode(errors="ignore")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_component(run_id: str) -> str:
|
|
48
|
+
safe = "".join(
|
|
49
|
+
c if c.isascii() and (c.isalnum() or c in "-_.") else "_" for c in run_id
|
|
50
|
+
)
|
|
51
|
+
safe = safe.strip("_") or "run"
|
|
52
|
+
if len(safe) <= _MAX_ARCHIVE_RUN_COMPONENT_BYTES:
|
|
53
|
+
return safe
|
|
54
|
+
return f"{_truncate_utf8(safe, _MAX_ARCHIVE_RUN_COMPONENT_BYTES)}_{fnv1a_hex(run_id)[:8]}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def archive_relative_path_for_run(
|
|
58
|
+
entity: str, filename: str, run_id: str, source_uri: str
|
|
59
|
+
) -> str:
|
|
60
|
+
name = filename.rsplit("/", 1)[-1] or filename
|
|
61
|
+
stem, _, ext = name.rpartition(".")
|
|
62
|
+
if not ext or not stem:
|
|
63
|
+
stem, ext = name, ""
|
|
64
|
+
suffix = f"__run-{_run_component(run_id)}__src-{fnv1a_hex(source_uri)}"
|
|
65
|
+
ext_suffix = f".{ext}" if ext else ""
|
|
66
|
+
available = _MAX_FILENAME_COMPONENT_BYTES - len(suffix) - len(ext_suffix.encode())
|
|
67
|
+
archived = f"{_truncate_utf8(stem, max(available, 0))}{suffix}{ext_suffix}"
|
|
68
|
+
entity = entity.strip("/")
|
|
69
|
+
return f"{entity}/{archived}" if entity else archived
|
floe/storage/resolve.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Storage reference resolution: name, else `storages.default`, else local (Rust `StorageResolver::resolve_path`)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from floe.config.storage import StorageDefinition, StoragesConfig
|
|
6
|
+
from floe.errors import FloeConfigError, FloeRunError
|
|
7
|
+
from floe.storage.local import LocalClient
|
|
8
|
+
from floe.storage.paths import normalize_local_path, parse_s3_uri
|
|
9
|
+
from floe.storage.s3 import S3Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _definition(
|
|
13
|
+
storages: StoragesConfig | None, name: str | None, field: str
|
|
14
|
+
) -> StorageDefinition | None:
|
|
15
|
+
"""`None` is the implicit local storage; unknown names raise, never fall back to local."""
|
|
16
|
+
where = name or (storages and storages.default) or "local"
|
|
17
|
+
if storages is None and where == "local":
|
|
18
|
+
return None
|
|
19
|
+
found = storages and next((d for d in storages.definitions if d.name == where), None) # fmt: skip
|
|
20
|
+
if not found:
|
|
21
|
+
raise FloeRunError(f"{field}={where} references unknown storage")
|
|
22
|
+
return found
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_path(
|
|
26
|
+
storages: StoragesConfig | None,
|
|
27
|
+
config_dir: Path,
|
|
28
|
+
name: str | None,
|
|
29
|
+
raw: str,
|
|
30
|
+
field: str,
|
|
31
|
+
) -> tuple[str, StorageDefinition | None]:
|
|
32
|
+
"""`raw` as a `local://` or `s3://` URI, with its storage definition."""
|
|
33
|
+
definition = _definition(storages, name, field)
|
|
34
|
+
if definition is None or definition.type == "local":
|
|
35
|
+
if "://" in raw:
|
|
36
|
+
raise FloeConfigError(f"{field} must be a local path (got {raw})")
|
|
37
|
+
return f"local://{normalize_local_path(config_dir / raw)}", definition
|
|
38
|
+
if definition.type != "s3":
|
|
39
|
+
raise FloeRunError(f"{field}={definition.name} (type {definition.type}) is not supported") # fmt: skip
|
|
40
|
+
if definition.bucket is None:
|
|
41
|
+
raise FloeConfigError(f"storage {definition.name} requires bucket for type s3") # fmt: skip
|
|
42
|
+
if raw.startswith("s3://"):
|
|
43
|
+
bucket, key = parse_s3_uri(raw)
|
|
44
|
+
if bucket != definition.bucket:
|
|
45
|
+
raise FloeConfigError(f"storage {definition.name} bucket mismatch: {bucket}") # fmt: skip
|
|
46
|
+
else:
|
|
47
|
+
key = "/".join(p for p in ((definition.prefix or "").strip("/"), raw.lstrip("/")) if p) # fmt: skip
|
|
48
|
+
return f"s3://{definition.bucket}/{key}" if key else f"s3://{definition.bucket}", definition # fmt: skip
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def storage_client(definition: StorageDefinition | None) -> LocalClient | S3Client:
|
|
52
|
+
"""S3 credentials come from the environment, as in Rust."""
|
|
53
|
+
if definition is None or definition.type == "local":
|
|
54
|
+
return LocalClient()
|
|
55
|
+
return S3Client(definition.bucket or "", definition.region, definition.endpoint, definition.path_style_access) # fmt: skip
|
floe/storage/s3.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""S3 storage client, ported from `io/storage/providers/s3.rs` (`S3Client`)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.config import Config
|
|
10
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
11
|
+
|
|
12
|
+
from floe.errors import FloeStorageError
|
|
13
|
+
from floe.storage.paths import fnv1a_hex, parse_s3_uri
|
|
14
|
+
from floe.storage.types import ObjectRef, StoredObject
|
|
15
|
+
|
|
16
|
+
_CONFLICT = ("PreconditionFailed", "ConditionalRequestConflict", "412", "409")
|
|
17
|
+
_NOT_FOUND = ("NoSuchKey", "NotFound", "404")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_path_style_access(configured: bool | None) -> bool:
|
|
21
|
+
if configured is not None:
|
|
22
|
+
return configured
|
|
23
|
+
env = os.environ.get("AWS_S3_FORCE_PATH_STYLE", "").strip()
|
|
24
|
+
return env == "1" or env.lower() == "true"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _code(err: ClientError) -> str:
|
|
28
|
+
return str(err.response.get("Error", {}).get("Code", ""))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is(err: ClientError, codes: tuple[str, ...]) -> bool:
|
|
32
|
+
status = str(err.response.get("ResponseMetadata", {}).get("HTTPStatusCode", ""))
|
|
33
|
+
return _code(err) in codes or status in codes
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _iso(ts: datetime | None) -> str | None:
|
|
37
|
+
return ts and ts.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def temp_path_for_key(temp_dir: Path, key: str) -> Path:
|
|
41
|
+
name = key.rsplit("/", 1)[-1] or "object"
|
|
42
|
+
safe = "".join(
|
|
43
|
+
c if c.isascii() and (c.isalnum() or c in "-_.") else "_" for c in name
|
|
44
|
+
)
|
|
45
|
+
return temp_dir / f"{fnv1a_hex(key)}_{safe}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class S3Client:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
bucket: str,
|
|
52
|
+
region: str | None = None,
|
|
53
|
+
endpoint: str | None = None,
|
|
54
|
+
path_style_access: bool | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.bucket = bucket
|
|
57
|
+
self._client = boto3.session.Session().client( # pyright: ignore[reportUnknownMemberType] # stubs cover s3 only
|
|
58
|
+
"s3",
|
|
59
|
+
region_name=region,
|
|
60
|
+
endpoint_url=endpoint,
|
|
61
|
+
config=Config(
|
|
62
|
+
s3={"addressing_style": "path"}
|
|
63
|
+
if resolve_path_style_access(path_style_access)
|
|
64
|
+
else None,
|
|
65
|
+
# boto3 >= 1.36 adds CRC checksums that S3-compatible services may reject.
|
|
66
|
+
request_checksum_calculation="when_required",
|
|
67
|
+
response_checksum_validation="when_required",
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
self.endpoint: str = self._client.meta.endpoint_url.rstrip("/") # pyright: ignore[reportUnknownMemberType] # identifies the store behind a bucket name
|
|
71
|
+
|
|
72
|
+
def resolve_uri(self, path: str) -> str:
|
|
73
|
+
key = path.lstrip("/")
|
|
74
|
+
return f"s3://{self.bucket}/{key}" if key else f"s3://{self.bucket}"
|
|
75
|
+
|
|
76
|
+
def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]:
|
|
77
|
+
"""Keys under `prefix`; D30: only those after `start_after`."""
|
|
78
|
+
try:
|
|
79
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
80
|
+
pages = (
|
|
81
|
+
paginator.paginate(Bucket=self.bucket, Prefix=prefix, StartAfter=start_after)
|
|
82
|
+
if start_after
|
|
83
|
+
else paginator.paginate(Bucket=self.bucket, Prefix=prefix)
|
|
84
|
+
) # fmt: skip
|
|
85
|
+
return [
|
|
86
|
+
ObjectRef(
|
|
87
|
+
f"s3://{self.bucket}/{o['Key']}",
|
|
88
|
+
o["Key"],
|
|
89
|
+
_iso(o.get("LastModified")),
|
|
90
|
+
o.get("Size"),
|
|
91
|
+
)
|
|
92
|
+
for page in pages
|
|
93
|
+
for o in page.get("Contents", [])
|
|
94
|
+
if "Key" in o
|
|
95
|
+
]
|
|
96
|
+
except (ClientError, BotoCoreError) as e:
|
|
97
|
+
raise FloeStorageError(
|
|
98
|
+
f"s3 list objects failed for bucket {self.bucket}: {e}"
|
|
99
|
+
) from e
|
|
100
|
+
|
|
101
|
+
def download(self, uri: str, temp_dir: Path) -> Path:
|
|
102
|
+
bucket, key = parse_s3_uri(uri)
|
|
103
|
+
dest = temp_path_for_key(temp_dir, key)
|
|
104
|
+
try:
|
|
105
|
+
body = self._client.get_object(Bucket=bucket, Key=key)["Body"]
|
|
106
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with dest.open("wb") as f:
|
|
108
|
+
shutil.copyfileobj(body, f)
|
|
109
|
+
except (ClientError, BotoCoreError, OSError) as e:
|
|
110
|
+
raise FloeStorageError(f"s3 get object failed: {e}") from e
|
|
111
|
+
return dest
|
|
112
|
+
|
|
113
|
+
def write(self, uri: str, body: bytes) -> None:
|
|
114
|
+
bucket, key = parse_s3_uri(uri)
|
|
115
|
+
try:
|
|
116
|
+
self._client.put_object(Bucket=bucket, Key=key, Body=body)
|
|
117
|
+
except (ClientError, BotoCoreError) as e:
|
|
118
|
+
raise FloeStorageError(f"s3 put object failed: {e}") from e
|
|
119
|
+
|
|
120
|
+
def copy(self, src_uri: str, dst_uri: str) -> None:
|
|
121
|
+
(sb, sk), (db, dk) = parse_s3_uri(src_uri), parse_s3_uri(dst_uri)
|
|
122
|
+
try:
|
|
123
|
+
self._client.copy_object(
|
|
124
|
+
Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}
|
|
125
|
+
)
|
|
126
|
+
except (ClientError, BotoCoreError) as e:
|
|
127
|
+
raise FloeStorageError(f"s3 copy object failed: {e}") from e
|
|
128
|
+
|
|
129
|
+
def delete(self, uri: str) -> None:
|
|
130
|
+
bucket, key = parse_s3_uri(uri)
|
|
131
|
+
try:
|
|
132
|
+
self._client.delete_object(Bucket=bucket, Key=key)
|
|
133
|
+
except (ClientError, BotoCoreError) as e:
|
|
134
|
+
raise FloeStorageError(f"s3 delete object failed: {e}") from e
|
|
135
|
+
|
|
136
|
+
def read(self, uri: str) -> StoredObject | None:
|
|
137
|
+
bucket, key = parse_s3_uri(uri)
|
|
138
|
+
try:
|
|
139
|
+
resp = self._client.get_object(Bucket=bucket, Key=key)
|
|
140
|
+
return StoredObject(resp["Body"].read(), resp.get("ETag", ""))
|
|
141
|
+
except ClientError as e:
|
|
142
|
+
if _is(e, _NOT_FOUND):
|
|
143
|
+
return None
|
|
144
|
+
raise FloeStorageError(f"s3 get object failed: {e}") from e
|
|
145
|
+
except BotoCoreError as e:
|
|
146
|
+
raise FloeStorageError(f"s3 get object failed: {e}") from e
|
|
147
|
+
|
|
148
|
+
def write_conditional(
|
|
149
|
+
self, uri: str, expected_version: str | None, body: bytes
|
|
150
|
+
) -> str | None:
|
|
151
|
+
bucket, key = parse_s3_uri(uri)
|
|
152
|
+
try:
|
|
153
|
+
if expected_version is None:
|
|
154
|
+
resp = self._client.put_object(
|
|
155
|
+
Bucket=bucket, Key=key, Body=body, IfNoneMatch="*"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
resp = self._client.put_object(
|
|
159
|
+
Bucket=bucket, Key=key, Body=body, IfMatch=expected_version
|
|
160
|
+
)
|
|
161
|
+
return resp.get("ETag", "")
|
|
162
|
+
except ClientError as e:
|
|
163
|
+
if _is(e, _CONFLICT):
|
|
164
|
+
return None
|
|
165
|
+
raise FloeStorageError(f"s3 put object failed: {e}") from e
|
|
166
|
+
except BotoCoreError as e:
|
|
167
|
+
raise FloeStorageError(f"s3 put object failed: {e}") from e
|
|
168
|
+
|
|
169
|
+
def delete_conditional(self, uri: str, expected_version: str | None) -> bool:
|
|
170
|
+
"""An If-Match delete; False on a conflict. `None` deletes nothing, as Rust: an object there is a concurrent runner's."""
|
|
171
|
+
if expected_version is None:
|
|
172
|
+
return True
|
|
173
|
+
bucket, key = parse_s3_uri(uri)
|
|
174
|
+
try:
|
|
175
|
+
self._client.delete_object(Bucket=bucket, Key=key, IfMatch=expected_version)
|
|
176
|
+
return True
|
|
177
|
+
except ClientError as e:
|
|
178
|
+
if _is(e, _CONFLICT):
|
|
179
|
+
return False
|
|
180
|
+
raise FloeStorageError(f"s3 delete object failed: {e}") from e
|
|
181
|
+
except BotoCoreError as e:
|
|
182
|
+
raise FloeStorageError(f"s3 delete object failed: {e}") from e
|
floe/storage/types.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Storage client types, ported from `io/storage/mod.rs` and `planner.rs`."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import NamedTuple, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ObjectRef(NamedTuple):
|
|
8
|
+
uri: str
|
|
9
|
+
key: str
|
|
10
|
+
last_modified: str | None
|
|
11
|
+
size: int | None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StoredObject(NamedTuple):
|
|
15
|
+
body: bytes
|
|
16
|
+
version: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StorageClient(Protocol):
|
|
20
|
+
def resolve_uri(self, path: str) -> str: ...
|
|
21
|
+
def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]: ...
|
|
22
|
+
def write(self, uri: str, body: bytes) -> None: ...
|
|
23
|
+
def copy(self, src_uri: str, dst_uri: str) -> None: ...
|
|
24
|
+
def delete(self, uri: str) -> None: ...
|
|
25
|
+
def read(self, uri: str) -> StoredObject | None: ...
|
|
26
|
+
def write_conditional(
|
|
27
|
+
self, uri: str, expected_version: str | None, body: bytes
|
|
28
|
+
) -> str | None: ...
|
|
29
|
+
def delete_conditional(self, uri: str, expected_version: str | None) -> bool: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Downloader(Protocol):
|
|
33
|
+
def download(self, uri: str, temp_dir: Path) -> Path: ...
|
floe/write/__init__.py
ADDED
|
File without changes
|