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/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
File without changes
@@ -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)