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/run/events.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Observer events: the shapes of `crates/floe-python/python/floe/_floe.pyi` (Rust: `run/events.rs`)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, TypedDict
|
|
8
|
+
|
|
9
|
+
Observer = Callable[[dict[str, Any]], None]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Emitter:
|
|
14
|
+
"""Stamps `run_id` and `ts_ms`; with parallel entities the observer is called from worker threads."""
|
|
15
|
+
|
|
16
|
+
observer: Observer | None = None
|
|
17
|
+
run_id: str = ""
|
|
18
|
+
|
|
19
|
+
def __call__(self, event: str, **fields: Any) -> None:
|
|
20
|
+
if self.observer:
|
|
21
|
+
with suppress(
|
|
22
|
+
Exception
|
|
23
|
+
): # an observer must not abort ingestion (Rust ignores callback errors)
|
|
24
|
+
self.observer(
|
|
25
|
+
{"event": event, "run_id": self.run_id, **fields}
|
|
26
|
+
| {"ts_ms": time.time_ns() // 1_000_000}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
NO_EVENTS = Emitter()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# The `RunEvent` dicts an observer receives, as typed in `_floe.pyi`.
|
|
34
|
+
RunStartedEvent = TypedDict("RunStartedEvent", {"event": Literal["run_started"], "run_id": str, "config": str, "report_base": str | None, "ts_ms": int}) # fmt: skip
|
|
35
|
+
EntityStartedEvent = TypedDict("EntityStartedEvent", {"event": Literal["entity_started"], "run_id": str, "name": str, "ts_ms": int}) # fmt: skip
|
|
36
|
+
FileStartedEvent = TypedDict("FileStartedEvent", {"event": Literal["file_started"], "run_id": str, "entity": str, "input": str, "ts_ms": int}) # fmt: skip
|
|
37
|
+
FileFinishedEvent = TypedDict("FileFinishedEvent", {"event": Literal["file_finished"], "run_id": str, "entity": str, "input": str, "status": str, "rows": int, "accepted": int, "rejected": int, "elapsed_ms": int, "ts_ms": int}) # fmt: skip
|
|
38
|
+
SchemaEvolutionAppliedEvent = TypedDict("SchemaEvolutionAppliedEvent", {"event": Literal["schema_evolution_applied"], "run_id": str, "entity": str, "mode": str, "added_columns": list[str], "ts_ms": int}) # fmt: skip
|
|
39
|
+
EntityFinishedEvent = TypedDict("EntityFinishedEvent", {"event": Literal["entity_finished"], "run_id": str, "name": str, "status": str, "files": int, "rows": int, "accepted": int, "rejected": int, "warnings": int, "errors": int, "ts_ms": int}) # fmt: skip
|
|
40
|
+
RunFinishedEvent = TypedDict("RunFinishedEvent", {"event": Literal["run_finished"], "run_id": str, "status": str, "exit_code": int, "files": int, "rows": int, "accepted": int, "rejected": int, "warnings": int, "errors": int, "summary_uri": str | None, "ts_ms": int}) # fmt: skip
|
|
41
|
+
LogEvent = TypedDict("LogEvent", {"event": Literal["log"], "run_id": str, "log_level": str, "code": str | None, "message": str, "entity": str | None, "input": str | None, "ts_ms": int}) # fmt: skip
|
|
42
|
+
RunEvent = RunStartedEvent | EntityStartedEvent | FileStartedEvent | FileFinishedEvent | SchemaEvolutionAppliedEvent | EntityFinishedEvent | RunFinishedEvent | LogEvent # fmt: skip
|
floe/state.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Incremental file state, ported from `state/mod.rs` (CAS claim, promote, release, renew, reset)."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from floe.errors import FloeConfigError
|
|
13
|
+
from floe.storage.inputs import InputFile
|
|
14
|
+
from floe.storage.types import StorageClient
|
|
15
|
+
|
|
16
|
+
SCHEMA_V1 = "floe.state.file-ingest.v1"
|
|
17
|
+
SCHEMA_V2 = "floe.state.file-ingest.v2"
|
|
18
|
+
CAS_RETRIES = 8
|
|
19
|
+
_BASE_BACKOFF_MS = 50
|
|
20
|
+
_MAX_BACKOFF_MS = 2_000
|
|
21
|
+
CLAIM_TTL_SECONDS = 60 * 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FileState:
|
|
26
|
+
processed_at: str
|
|
27
|
+
size: int | None = None
|
|
28
|
+
mtime: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Claim:
|
|
33
|
+
run_id: str
|
|
34
|
+
acquired_at: str
|
|
35
|
+
expires_at: str
|
|
36
|
+
size: int | None = None
|
|
37
|
+
mtime: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EntityState:
|
|
42
|
+
entity: str
|
|
43
|
+
updated_at: str | None
|
|
44
|
+
files: dict[str, FileState]
|
|
45
|
+
claims: dict[str, Claim]
|
|
46
|
+
watermark: str | None = None # D30: the latest partition date ingested (ISO)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Loaded(NamedTuple):
|
|
50
|
+
state: EntityState
|
|
51
|
+
version: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ClaimOutcome(NamedTuple):
|
|
55
|
+
pending: list[InputFile]
|
|
56
|
+
claimed: Loaded | None
|
|
57
|
+
active_claims: list[str]
|
|
58
|
+
already_processed: list[tuple[InputFile, FileState]]
|
|
59
|
+
expired: dict[str, Claim] # dropped claims of dead runs, by URI (#545 retry guard)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now(offset_s: int = 0) -> str:
|
|
63
|
+
at = datetime.now(UTC) + timedelta(seconds=offset_s)
|
|
64
|
+
return at.isoformat().replace("+00:00", "Z")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dump_state(state: EntityState) -> bytes:
|
|
68
|
+
doc = {
|
|
69
|
+
"schema": SCHEMA_V2,
|
|
70
|
+
"entity": state.entity,
|
|
71
|
+
"updated_at": state.updated_at,
|
|
72
|
+
"files": {k: asdict(v) for k, v in sorted(state.files.items())},
|
|
73
|
+
"claims": {k: asdict(v) for k, v in sorted(state.claims.items())},
|
|
74
|
+
}
|
|
75
|
+
if state.watermark: # D30: additive, absent without partitioning
|
|
76
|
+
doc["watermark"] = state.watermark
|
|
77
|
+
return json.dumps(doc, indent=2, ensure_ascii=False).encode()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_state(entity: str, body: bytes) -> EntityState:
|
|
81
|
+
try:
|
|
82
|
+
doc = json.loads(body)
|
|
83
|
+
schema: str = doc["schema"]
|
|
84
|
+
state = EntityState(
|
|
85
|
+
doc["entity"],
|
|
86
|
+
doc.get("updated_at"),
|
|
87
|
+
{k: FileState(**v) for k, v in doc.get("files", {}).items()},
|
|
88
|
+
{k: Claim(**v) for k, v in doc.get("claims", {}).items()}
|
|
89
|
+
if schema != SCHEMA_V1
|
|
90
|
+
else {},
|
|
91
|
+
doc.get("watermark"),
|
|
92
|
+
)
|
|
93
|
+
except (ValueError, KeyError, TypeError, AttributeError) as e:
|
|
94
|
+
raise FloeConfigError(f"entity.name={entity} invalid state file: {e}") from e
|
|
95
|
+
if schema not in (SCHEMA_V1, SCHEMA_V2):
|
|
96
|
+
raise FloeConfigError(
|
|
97
|
+
f"entity.name={entity} state schema mismatch: expected {SCHEMA_V1} or {SCHEMA_V2}, got {schema}"
|
|
98
|
+
)
|
|
99
|
+
if state.entity != entity:
|
|
100
|
+
raise FloeConfigError(
|
|
101
|
+
f"entity.name={entity} state entity mismatch: expected {entity}, got {state.entity}"
|
|
102
|
+
)
|
|
103
|
+
return state
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_state(client: StorageClient, uri: str, entity: str) -> EntityState | None:
|
|
107
|
+
stored = client.read(uri)
|
|
108
|
+
return None if stored is None else parse_state(entity, stored.body)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load(client: StorageClient, uri: str, entity: str) -> Loaded:
|
|
112
|
+
stored = client.read(uri)
|
|
113
|
+
if stored is None:
|
|
114
|
+
return Loaded(EntityState(entity, None, {}, {}), None)
|
|
115
|
+
return Loaded(parse_state(entity, stored.body), stored.version)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _backoff(attempt: int) -> None:
|
|
119
|
+
window = min(_BASE_BACKOFF_MS * 2 ** min(attempt, 20), _MAX_BACKOFF_MS)
|
|
120
|
+
time.sleep(random.uniform(0, window) / 1000)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_live(claim: Claim, now: datetime) -> bool:
|
|
124
|
+
try:
|
|
125
|
+
return datetime.fromisoformat(claim.expires_at) > now
|
|
126
|
+
except (ValueError, TypeError):
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _persist(client: StorageClient, uri: str, loaded: Loaded) -> str | None:
|
|
131
|
+
return client.write_conditional(uri, loaded.version, dump_state(loaded.state))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def claim_inputs(
|
|
135
|
+
client: StorageClient,
|
|
136
|
+
uri: str,
|
|
137
|
+
entity: str,
|
|
138
|
+
run_id: str,
|
|
139
|
+
inputs: list[InputFile],
|
|
140
|
+
barrier: bool = False,
|
|
141
|
+
) -> ClaimOutcome:
|
|
142
|
+
"""`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`."""
|
|
143
|
+
if not inputs:
|
|
144
|
+
return ClaimOutcome([], None, [], [], {})
|
|
145
|
+
for attempt in range(CAS_RETRIES):
|
|
146
|
+
if attempt:
|
|
147
|
+
_backoff(attempt - 1)
|
|
148
|
+
loaded = _load(client, uri, entity)
|
|
149
|
+
state = loaded.state
|
|
150
|
+
now = datetime.now(UTC)
|
|
151
|
+
expired = {u: c for u, c in state.claims.items() if not _is_live(c, now)}
|
|
152
|
+
state.claims = {u: c for u, c in state.claims.items() if u not in expired}
|
|
153
|
+
# only live claims are left, and none is ours
|
|
154
|
+
blocked = barrier and bool(state.claims)
|
|
155
|
+
pending: list[InputFile] = []
|
|
156
|
+
active: list[str] = []
|
|
157
|
+
processed: list[tuple[InputFile, FileState]] = []
|
|
158
|
+
acquired_at, expires_at = _now(), _now(CLAIM_TTL_SECONDS)
|
|
159
|
+
for f in inputs:
|
|
160
|
+
if f.source_uri in state.files:
|
|
161
|
+
processed.append((f, state.files[f.source_uri]))
|
|
162
|
+
elif blocked or f.source_uri in state.claims:
|
|
163
|
+
active.append(f.source_uri)
|
|
164
|
+
else:
|
|
165
|
+
# a dead run's batch can't have committed before its first claim (#545): keep that bound
|
|
166
|
+
was = expired.get(f.source_uri)
|
|
167
|
+
since = was.acquired_at if was else acquired_at
|
|
168
|
+
state.claims[f.source_uri] = Claim(
|
|
169
|
+
run_id, since, expires_at, f.source_size, f.source_mtime
|
|
170
|
+
)
|
|
171
|
+
pending.append(f)
|
|
172
|
+
if not pending:
|
|
173
|
+
if not active:
|
|
174
|
+
_persist(client, uri, loaded)
|
|
175
|
+
return ClaimOutcome([], None, active, processed, expired)
|
|
176
|
+
state.updated_at = acquired_at
|
|
177
|
+
version = _persist(client, uri, loaded)
|
|
178
|
+
if version is not None:
|
|
179
|
+
return ClaimOutcome(
|
|
180
|
+
pending, Loaded(state, version), active, processed, expired
|
|
181
|
+
)
|
|
182
|
+
raise FloeConfigError(
|
|
183
|
+
f"entity.name={entity} incremental state update conflicted after {CAS_RETRIES} retries"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def claim_all(
|
|
188
|
+
client: StorageClient, uri: str, entity: str, run_id: str, inputs: list[InputFile]
|
|
189
|
+
) -> Loaded:
|
|
190
|
+
"""Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`)."""
|
|
191
|
+
for attempt in range(CAS_RETRIES):
|
|
192
|
+
if attempt:
|
|
193
|
+
_backoff(attempt - 1)
|
|
194
|
+
loaded = _load(client, uri, entity)
|
|
195
|
+
acquired_at, expires_at = _now(), _now(CLAIM_TTL_SECONDS)
|
|
196
|
+
claims = {
|
|
197
|
+
f.source_uri: Claim(run_id, acquired_at, expires_at, f.source_size, f.source_mtime)
|
|
198
|
+
for f in inputs
|
|
199
|
+
} # fmt: skip
|
|
200
|
+
# D30: no watermark until the refresh promotes, so a failed one leaves the next run listing everything
|
|
201
|
+
fresh = Loaded(EntityState(entity, acquired_at, loaded.state.files, claims), loaded.version) # fmt: skip
|
|
202
|
+
version = _persist(client, uri, fresh)
|
|
203
|
+
if version is not None:
|
|
204
|
+
return Loaded(fresh.state, version)
|
|
205
|
+
raise FloeConfigError(
|
|
206
|
+
f"entity.name={entity} full-refresh state write conflicted after {CAS_RETRIES} retries"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _mutate(
|
|
211
|
+
client: StorageClient,
|
|
212
|
+
uri: str,
|
|
213
|
+
claimed: Loaded,
|
|
214
|
+
mutate: Callable[[EntityState, set[str]], None],
|
|
215
|
+
) -> None:
|
|
216
|
+
entity = claimed.state.entity
|
|
217
|
+
ours = set(claimed.state.claims)
|
|
218
|
+
for attempt in range(CAS_RETRIES):
|
|
219
|
+
if attempt:
|
|
220
|
+
_backoff(attempt - 1)
|
|
221
|
+
loaded = (
|
|
222
|
+
Loaded(copy.deepcopy(claimed.state), claimed.version)
|
|
223
|
+
if attempt == 0
|
|
224
|
+
else _load(client, uri, entity)
|
|
225
|
+
)
|
|
226
|
+
mutate(loaded.state, ours)
|
|
227
|
+
state = loaded.state
|
|
228
|
+
if state.files or state.claims or state.watermark:
|
|
229
|
+
done = _persist(client, uri, loaded) is not None
|
|
230
|
+
else: # Rust deletes an emptied state
|
|
231
|
+
done = client.delete_conditional(uri, loaded.version)
|
|
232
|
+
if done:
|
|
233
|
+
return
|
|
234
|
+
raise FloeConfigError(
|
|
235
|
+
f"entity.name={entity} incremental state update conflicted after {CAS_RETRIES} retries"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def promote(
|
|
240
|
+
client: StorageClient,
|
|
241
|
+
uri: str,
|
|
242
|
+
run_id: str,
|
|
243
|
+
claimed: Loaded,
|
|
244
|
+
full_refresh: bool = False,
|
|
245
|
+
only: set[str] | None = None,
|
|
246
|
+
advance: Callable[[EntityState, list[str]], None] | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""A full refresh replaces `files` with this run's (Rust `promote_full_refresh_claimed_entity_state`); `only` limits the URIs.
|
|
249
|
+
|
|
250
|
+
`advance` (D30) gets the promoted URIs, in the same state write.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def apply(state: EntityState, ours: set[str]) -> None:
|
|
254
|
+
processed_at = _now()
|
|
255
|
+
if full_refresh:
|
|
256
|
+
state.files.clear()
|
|
257
|
+
mine = ours if only is None else ours & only
|
|
258
|
+
promoted = [
|
|
259
|
+
u for u, c in state.claims.items() if c.run_id == run_id and u in mine
|
|
260
|
+
]
|
|
261
|
+
for u in promoted:
|
|
262
|
+
c = state.claims.pop(u)
|
|
263
|
+
state.files[u] = FileState(processed_at, c.size, c.mtime)
|
|
264
|
+
if advance:
|
|
265
|
+
advance(state, promoted)
|
|
266
|
+
state.updated_at = processed_at
|
|
267
|
+
|
|
268
|
+
_mutate(client, uri, claimed, apply)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def release(client: StorageClient, uri: str, run_id: str, claimed: Loaded, restore: dict[str, Claim] | None = None, expire: bool = False) -> None: # fmt: skip
|
|
272
|
+
"""`restore`: the dead runs' expired claims this run took over, put back as they were (#545).
|
|
273
|
+
|
|
274
|
+
`expire` (D30): this run's other claims stay, expired, so they hold the listing window open until a run claims the files again.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def apply(state: EntityState, ours: set[str]) -> None:
|
|
278
|
+
mine = {u: c for u, c in state.claims.items() if c.run_id == run_id and u in ours} # fmt: skip
|
|
279
|
+
state.claims = {u: c for u, c in state.claims.items() if u not in mine}
|
|
280
|
+
for u, c in (restore or {}).items():
|
|
281
|
+
if u in mine: # not one it promoted: D30 may have pruned it
|
|
282
|
+
state.claims.setdefault(u, c)
|
|
283
|
+
for u, c in mine.items() if expire else ():
|
|
284
|
+
c.expires_at = c.acquired_at
|
|
285
|
+
state.claims.setdefault(u, c)
|
|
286
|
+
state.updated_at = _now()
|
|
287
|
+
|
|
288
|
+
_mutate(client, uri, claimed, apply)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def renew(client: StorageClient, uri: str, run_id: str, claimed: Loaded) -> None:
|
|
292
|
+
def apply(state: EntityState, ours: set[str]) -> None:
|
|
293
|
+
expires_at = _now(CLAIM_TTL_SECONDS)
|
|
294
|
+
for u, c in state.claims.items():
|
|
295
|
+
if c.run_id == run_id and u in ours:
|
|
296
|
+
c.expires_at = expires_at
|
|
297
|
+
state.updated_at = _now()
|
|
298
|
+
|
|
299
|
+
_mutate(client, uri, claimed, apply)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def reset(client: StorageClient, uri: str, entity: str) -> bool:
|
|
303
|
+
"""Delete the state file, even a malformed one; False when there is none (Rust `reset_entity_state`)."""
|
|
304
|
+
stored = client.read(uri)
|
|
305
|
+
if stored is None:
|
|
306
|
+
return False
|
|
307
|
+
if not client.delete_conditional(uri, stored.version):
|
|
308
|
+
raise FloeConfigError(
|
|
309
|
+
f"entity.name={entity} remote state changed while resetting: {uri}"
|
|
310
|
+
)
|
|
311
|
+
return True
|
floe/storage/__init__.py
ADDED
|
File without changes
|
floe/storage/archive.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Input archiving, ported from `io/storage/ops/archive.rs`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from floe.errors import FloeError, FloeRunError
|
|
6
|
+
from floe.storage.inputs import InputFile
|
|
7
|
+
from floe.storage.paths import archive_relative_path_for_run
|
|
8
|
+
from floe.storage.types import StorageClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def archive_input_file(
|
|
12
|
+
client: StorageClient,
|
|
13
|
+
run_id: str,
|
|
14
|
+
entity_name: str,
|
|
15
|
+
join_relative: Callable[[str], str],
|
|
16
|
+
input_file: InputFile,
|
|
17
|
+
subdir: str = "",
|
|
18
|
+
) -> str:
|
|
19
|
+
"""`subdir` (D29): the partition sub-path kept between the entity folder and the file name."""
|
|
20
|
+
relative = archive_relative_path_for_run(
|
|
21
|
+
f"{entity_name}/{subdir}" if subdir else entity_name, input_file.source_name, run_id, input_file.source_uri
|
|
22
|
+
) # fmt: skip
|
|
23
|
+
dest_uri = join_relative(relative)
|
|
24
|
+
client.copy(input_file.source_uri, dest_uri)
|
|
25
|
+
try:
|
|
26
|
+
client.delete(input_file.source_uri)
|
|
27
|
+
except FloeError as e:
|
|
28
|
+
raise FloeRunError(
|
|
29
|
+
f"entity.name={entity_name} archive delete failed for {input_file.source_uri}: {e}"
|
|
30
|
+
) from e
|
|
31
|
+
return dest_uri
|
floe/storage/inputs.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Input resolution, ported from `io/storage/ops/inputs.rs` (`resolve_inputs`)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path, PurePath
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from floe.errors import FloeConfigError, FloeError, FloeRunError
|
|
10
|
+
from floe.glob import MatchOptions, Pattern, PatternError, glob
|
|
11
|
+
from floe.storage.paths import normalize_local_path, parse_s3_uri
|
|
12
|
+
from floe.storage.types import Downloader, ObjectRef, StorageClient
|
|
13
|
+
|
|
14
|
+
_SUFFIXES = {
|
|
15
|
+
"csv": [".csv"],
|
|
16
|
+
"tsv": [".tsv"],
|
|
17
|
+
"parquet": [".parquet"],
|
|
18
|
+
"json": [".json", ".jsonl", ".ndjson", ".djson"],
|
|
19
|
+
"avro": [".avro"],
|
|
20
|
+
"orc": [".orc"],
|
|
21
|
+
"xlsx": [".xlsx"],
|
|
22
|
+
"xml": [".xml"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NoInputsError(FloeRunError):
|
|
27
|
+
"""No input file or object matched the source; any other listing error is not this."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class InputFile:
|
|
32
|
+
source_uri: str
|
|
33
|
+
source_name: str
|
|
34
|
+
source_stem: str
|
|
35
|
+
source_size: int | None
|
|
36
|
+
source_mtime: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LocalInputFile:
|
|
41
|
+
file: InputFile
|
|
42
|
+
local_path: Path
|
|
43
|
+
is_ephemeral: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ResolvedInputs:
|
|
48
|
+
files: list[InputFile]
|
|
49
|
+
listed: list[str]
|
|
50
|
+
mode: Literal["file", "directory"]
|
|
51
|
+
warning: str | None = None # D30: the listing window, logged once per run
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def suffixes_for_format(fmt: str) -> list[str]:
|
|
55
|
+
if fmt not in _SUFFIXES:
|
|
56
|
+
raise FloeConfigError(f"unsupported source format for input resolution: {fmt}")
|
|
57
|
+
return _SUFFIXES[fmt]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _rfc3339(ns: int) -> str:
|
|
61
|
+
secs, nanos = divmod(ns, 10**9)
|
|
62
|
+
text = datetime.fromtimestamp(secs, UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
|
63
|
+
return f"{text}.{nanos:09d}".rstrip("0") + "Z" if nanos else f"{text}Z"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _glob_files(pattern: str) -> list[Path]:
|
|
67
|
+
try:
|
|
68
|
+
found = glob(pattern)
|
|
69
|
+
except PatternError as e:
|
|
70
|
+
raise FloeConfigError(f'invalid glob pattern "{pattern}": {e}') from e
|
|
71
|
+
except FloeError as e:
|
|
72
|
+
raise FloeConfigError(f'glob match failed for "{pattern}": {e}') from e
|
|
73
|
+
return [normalize_local_path(Path(p)) for p in found if Path(p).is_file()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _visible(files: list[Path], root: Path, exclude: Path | None) -> list[Path]:
|
|
77
|
+
"""D23: drop anything under a `.floe` directory (the default state location), as the cloud listing does; D31: and under `exclude`."""
|
|
78
|
+
root = normalize_local_path(
|
|
79
|
+
root
|
|
80
|
+
) # the matches are normalised, so `..` in the root must be too
|
|
81
|
+
return [
|
|
82
|
+
f
|
|
83
|
+
for f in files
|
|
84
|
+
if ".floe" not in f.relative_to(root).parts[:-1]
|
|
85
|
+
and not (exclude and f.is_relative_to(exclude))
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _local_files(
|
|
90
|
+
config_dir: Path,
|
|
91
|
+
raw: str,
|
|
92
|
+
fmt: str,
|
|
93
|
+
entity: str,
|
|
94
|
+
storage: str,
|
|
95
|
+
glob_override: str | None,
|
|
96
|
+
recursive: bool,
|
|
97
|
+
exclude: Path | None,
|
|
98
|
+
after: str = "",
|
|
99
|
+
) -> tuple[list[Path], Literal["file", "directory"]]:
|
|
100
|
+
def no_match(base: object, used: str) -> NoInputsError:
|
|
101
|
+
return NoInputsError(
|
|
102
|
+
f"entity.name={entity} source.storage={storage} no input files matched "
|
|
103
|
+
f"(base_path={base}, glob={used}, recursive={str(recursive).lower()})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if any(c in raw for c in "*?["):
|
|
107
|
+
pattern = os.path.join(config_dir, raw)
|
|
108
|
+
prefix = pattern[: next(i for i, c in enumerate(pattern) if c in "*?[")]
|
|
109
|
+
root = Path(prefix) if prefix.endswith(os.sep) else Path(prefix).parent
|
|
110
|
+
files = _visible(sorted(set(_glob_files(pattern)), key=str), root, exclude)
|
|
111
|
+
if not files:
|
|
112
|
+
raise no_match(
|
|
113
|
+
os.path.dirname(pattern), os.path.basename(pattern)
|
|
114
|
+
) # as written: Rust keeps a leading `./`
|
|
115
|
+
return files, "directory"
|
|
116
|
+
base = normalize_local_path(config_dir / raw)
|
|
117
|
+
if base.is_file():
|
|
118
|
+
return [base], "file"
|
|
119
|
+
globs = (
|
|
120
|
+
[glob_override]
|
|
121
|
+
if glob_override is not None
|
|
122
|
+
else [
|
|
123
|
+
"*" + "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else c for c in s)
|
|
124
|
+
for s in suffixes_for_format(fmt)
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
if not base.is_dir():
|
|
128
|
+
raise no_match(base, ",".join(globs))
|
|
129
|
+
patterns = [
|
|
130
|
+
os.path.join(d, "**", g) if recursive else os.path.join(d, g)
|
|
131
|
+
# D30: a glob override with a `/` keeps its meaning only from `base`, so it gets the after-listing filter alone
|
|
132
|
+
for d in (
|
|
133
|
+
_window_dirs(base, after)
|
|
134
|
+
if after and "/" not in (glob_override or "")
|
|
135
|
+
else [base]
|
|
136
|
+
)
|
|
137
|
+
for g in globs
|
|
138
|
+
]
|
|
139
|
+
files = _visible(
|
|
140
|
+
sorted({f for p in patterns for f in _glob_files(p)}, key=str), base, exclude
|
|
141
|
+
)
|
|
142
|
+
if not files:
|
|
143
|
+
raise no_match(base, ",".join(globs))
|
|
144
|
+
return files, "directory"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _window_dirs(base: Path, after: str) -> list[Path]:
|
|
148
|
+
"""D30: the directories at the depth of `after` (relative to `base`) that do not sort before it; older ones are never walked."""
|
|
149
|
+
dirs = [base]
|
|
150
|
+
for _ in after.split("/"):
|
|
151
|
+
dirs = sorted(d for p in dirs for d in p.iterdir() if d.is_dir() and (r := d.relative_to(base).as_posix()) >= after[: len(r)]) # fmt: skip
|
|
152
|
+
return dirs
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_REMOTE_SCHEMES = ("s3://", "gs://", "gcs://", "abfs://", "abfss://", "az://")
|
|
156
|
+
_CLOUD = MatchOptions(case_sensitive=True, require_literal_separator=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _first_glob(value: str) -> int:
|
|
160
|
+
escaped = False
|
|
161
|
+
for i, ch in enumerate(value):
|
|
162
|
+
if escaped:
|
|
163
|
+
escaped = False
|
|
164
|
+
elif ch == "\\":
|
|
165
|
+
escaped = True
|
|
166
|
+
elif ch in "*?":
|
|
167
|
+
return i
|
|
168
|
+
return -1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cloud_refs(
|
|
172
|
+
client: StorageClient,
|
|
173
|
+
key: str,
|
|
174
|
+
suffixes: list[str],
|
|
175
|
+
entity: str,
|
|
176
|
+
storage: str,
|
|
177
|
+
location: str,
|
|
178
|
+
exclude: str | None,
|
|
179
|
+
after: str = "",
|
|
180
|
+
) -> list[ObjectRef]:
|
|
181
|
+
where = f"entity.name={entity} source.storage={storage}"
|
|
182
|
+
first = _first_glob(key)
|
|
183
|
+
prefix = key if first == -1 else key[:first]
|
|
184
|
+
matcher = None
|
|
185
|
+
if first != -1:
|
|
186
|
+
try:
|
|
187
|
+
if not prefix.strip("/"):
|
|
188
|
+
raise FloeRunError(
|
|
189
|
+
"glob patterns for cloud sources must include a non-empty "
|
|
190
|
+
"literal prefix before the first wildcard"
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
matcher = Pattern(key)
|
|
194
|
+
except PatternError as e:
|
|
195
|
+
raise FloeRunError(f'invalid cloud glob pattern "{key}": {e}') from e
|
|
196
|
+
except FloeRunError as e:
|
|
197
|
+
raise FloeRunError(
|
|
198
|
+
f"{where} invalid cloud source path ({location}, path={key}): {e}"
|
|
199
|
+
) from e
|
|
200
|
+
lowered = tuple(s.lower() for s in suffixes)
|
|
201
|
+
refs = [
|
|
202
|
+
r
|
|
203
|
+
for r in client.list(prefix, after)
|
|
204
|
+
if (matcher is None or matcher.matches_with(r.key, _CLOUD))
|
|
205
|
+
and not (r.key.startswith(".floe/") or "/.floe/" in r.key)
|
|
206
|
+
and not (exclude and r.uri.startswith(f"{exclude}/"))
|
|
207
|
+
and r.uri.lower().endswith(lowered)
|
|
208
|
+
]
|
|
209
|
+
if not refs:
|
|
210
|
+
used = "glob=<none>" if matcher is None else f"glob={key}"
|
|
211
|
+
raise NoInputsError(
|
|
212
|
+
f"{where} no input objects matched ({location}, prefix={prefix}, "
|
|
213
|
+
f"{used}, suffixes={','.join(suffixes)})"
|
|
214
|
+
)
|
|
215
|
+
return sorted(refs, key=lambda r: r.uri)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_inputs(
|
|
219
|
+
config_dir: Path,
|
|
220
|
+
path: str,
|
|
221
|
+
fmt: str,
|
|
222
|
+
entity: str,
|
|
223
|
+
storage: str,
|
|
224
|
+
client: StorageClient | None,
|
|
225
|
+
*,
|
|
226
|
+
list_only: bool = False,
|
|
227
|
+
recursive: bool = False,
|
|
228
|
+
glob_override: str | None = None,
|
|
229
|
+
exclude: str | None = None,
|
|
230
|
+
after: str = "",
|
|
231
|
+
) -> ResolvedInputs:
|
|
232
|
+
"""`path` is the source path, or a full `s3://` URI for a remote storage; nothing under the `exclude` URI is listed (D31).
|
|
233
|
+
|
|
234
|
+
D30: `after`, a URI, skips what sorts before it: S3 `StartAfter`, or local directories never walked.
|
|
235
|
+
"""
|
|
236
|
+
files: list[InputFile] = []
|
|
237
|
+
if path.startswith("s3://"):
|
|
238
|
+
if client is None:
|
|
239
|
+
raise FloeRunError("s3 storage client missing")
|
|
240
|
+
bucket, key = parse_s3_uri(path)
|
|
241
|
+
refs = _cloud_refs(
|
|
242
|
+
client,
|
|
243
|
+
key,
|
|
244
|
+
suffixes_for_format(fmt),
|
|
245
|
+
entity,
|
|
246
|
+
storage,
|
|
247
|
+
f"bucket={bucket}",
|
|
248
|
+
exclude,
|
|
249
|
+
parse_s3_uri(after)[1] if after else "",
|
|
250
|
+
)
|
|
251
|
+
if not list_only:
|
|
252
|
+
for r in refs:
|
|
253
|
+
name = PurePath(r.key).name or entity
|
|
254
|
+
files.append(
|
|
255
|
+
InputFile(
|
|
256
|
+
r.uri,
|
|
257
|
+
name,
|
|
258
|
+
PurePath(name).stem or entity,
|
|
259
|
+
r.size,
|
|
260
|
+
r.last_modified,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return ResolvedInputs(files, [r.uri for r in refs], "directory")
|
|
264
|
+
if "://" in path:
|
|
265
|
+
raise FloeConfigError(f"unsupported storage uri: {path}")
|
|
266
|
+
skip = Path(exclude.removeprefix("local://")) if exclude and exclude.startswith("local://") else None # fmt: skip
|
|
267
|
+
start = Path(after.removeprefix("local://")) if after.startswith("local://") else None # fmt: skip
|
|
268
|
+
base = normalize_local_path(config_dir / path)
|
|
269
|
+
paths, mode = _local_files(
|
|
270
|
+
config_dir, path, fmt, entity, storage, glob_override, recursive, skip,
|
|
271
|
+
start.relative_to(base).as_posix() if start and start.is_relative_to(base) else "",
|
|
272
|
+
) # fmt: skip
|
|
273
|
+
uris = [client.resolve_uri(str(p)) if client else str(p) for p in paths]
|
|
274
|
+
if not list_only:
|
|
275
|
+
for p, uri in zip(paths, uris, strict=True):
|
|
276
|
+
st = p.stat()
|
|
277
|
+
name = p.name or entity
|
|
278
|
+
files.append(
|
|
279
|
+
InputFile(
|
|
280
|
+
uri,
|
|
281
|
+
name,
|
|
282
|
+
PurePath(name).stem or entity,
|
|
283
|
+
st.st_size,
|
|
284
|
+
_rfc3339(st.st_mtime_ns),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
return ResolvedInputs(files, uris, mode)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def localize_input(
|
|
291
|
+
file: InputFile, client: Downloader | None, temp_dir: Path | None
|
|
292
|
+
) -> LocalInputFile:
|
|
293
|
+
"""Download a cloud input to `temp_dir`; a local input is used in place."""
|
|
294
|
+
if not file.source_uri.startswith(_REMOTE_SCHEMES):
|
|
295
|
+
return LocalInputFile(
|
|
296
|
+
file, Path(file.source_uri.removeprefix("local://")), False
|
|
297
|
+
)
|
|
298
|
+
if client is None:
|
|
299
|
+
raise FloeRunError(f"storage client required to download {file.source_uri}")
|
|
300
|
+
if temp_dir is None:
|
|
301
|
+
raise FloeRunError(f"temp_dir required to download {file.source_uri}")
|
|
302
|
+
return LocalInputFile(file, client.download(file.source_uri, temp_dir), True)
|