floe-python 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/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
@@ -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