floe-python 0.8.9__py3-none-any.whl → 0.9.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/config/profile.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
2
2
 
3
+ import math
3
4
  import os
4
5
  from collections.abc import Callable, Mapping
5
6
  from dataclasses import dataclass
@@ -45,7 +46,8 @@ CONTRACT = {
45
46
  @dataclass(frozen=True)
46
47
  class ProfileConfig:
47
48
  name: str
48
- runner: dict[str, object] # `type` and every field, None when unset
49
+ # `type` and every field, None when unset; then a kubernetes_job's pass-through keys
50
+ runner: dict[str, object]
49
51
  variables: dict[str, str]
50
52
  catalogs: CatalogsConfig | None
51
53
  storages: StoragesConfig | None
@@ -157,17 +159,6 @@ def _str_keyed(value: object, where: str) -> dict[str, object]:
157
159
  return cast(dict[str, object], hash)
158
160
 
159
161
 
160
- def _object(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
161
- return _str_keyed(hash[key], f"{ctx}.{key}") if key in hash else None
162
-
163
-
164
- def _objects(hash: Hash, key: str, ctx: str) -> list[dict[str, object]] | None:
165
- if key not in hash:
166
- return None
167
- where = f"{ctx}.{key}"
168
- return [_str_keyed(item, f"{where}[]") for item in yaml_array(hash[key], where)]
169
-
170
-
171
162
  def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
172
163
  if key not in hash:
173
164
  return None
@@ -200,24 +191,41 @@ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
200
191
  ("env_parameters", _map),
201
192
  ("job_scope", _job_scope),
202
193
  ("domain_resources", _domain_resources),
203
- ("labels", _map),
204
- ("annotations", _map),
205
- ("image_pull_policy", _string),
206
- ("security_context", _object),
207
- ("node_selector", _map),
208
- ("tolerations", _objects),
209
- ("backoff_limit", _u64),
210
- ("env_from", _objects),
211
194
  )
195
+ # Legacy: the keys any runner type accepted before #1065. A non-k8s runner still accepts and ignores them,
196
+ # so a profile copied from a kubernetes_job keeps loading; they are not validated here.
197
+ _LEGACY_IGNORED = (
198
+ *("labels", "annotations", "image_pull_policy", "security_context"),
199
+ *("node_selector", "tolerations", "backoff_limit", "env_from"),
200
+ )
201
+
202
+
203
+ def _json(value: object, where: str) -> object:
204
+ """A pass-through value as written, once JSON can carry it: string mapping keys, finite numbers."""
205
+ if isinstance(value, dict):
206
+ hash = _str_keyed(cast(object, value), where)
207
+ return {k: _json(v, f"{where}.{k}") for k, v in hash.items()}
208
+ if isinstance(value, list):
209
+ return [_json(v, f"{where}[]") for v in cast(list[object], value)]
210
+ if isinstance(value, float) and not math.isfinite(value):
211
+ raise FloeConfigError(f"{where} must be a finite number")
212
+ return value
212
213
 
213
214
 
214
215
  def _runner(value: object) -> dict[str, object]:
215
216
  ctx = "profile.execution.runner"
216
217
  hash = yaml_hash(value, ctx)
217
- validate_known_keys(hash, ctx, ("type", *(name for name, _ in _RUNNER_FIELDS)))
218
+ known = ("type", *(name for name, _ in _RUNNER_FIELDS))
219
+ # #1065: a kubernetes_job's other keys are pod options its orchestrator validates; they pass through
220
+ open_ = hash.get("type") == "kubernetes_job"
221
+ validate_known_keys(hash, ctx, hash.keys() if open_ else (*known, *_LEGACY_IGNORED))
218
222
  read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
219
223
  for name, reader in _RUNNER_FIELDS:
220
224
  read[name] = reader(hash, name, ctx)
225
+ if open_:
226
+ read |= {
227
+ str(k): _json(v, f"{ctx}.{k}") for k, v in hash.items() if k not in known
228
+ }
221
229
  return read
222
230
 
223
231
 
floe/iceberg/compact.py CHANGED
@@ -71,7 +71,9 @@ class _RewriteFiles(_OverwriteFiles):
71
71
  for e in entries:
72
72
  writer.delete(e) if e.data_file in self._deleted_data_files else writer.existing(e) # fmt: skip
73
73
  out.append(writer.to_manifest_file())
74
- return out
74
+ return [
75
+ m for m in out if m.has_added_files() or m.has_existing_files()
76
+ ] # #1136: as `_LiveOverwrite`
75
77
 
76
78
  def _summary(self, snapshot_properties: Mapping[str, str] = {}) -> Summary:
77
79
  # built as an OVERWRITE: PyIceberg 0.12's `update_snapshot_summaries` rejects REPLACE
floe/manifest.py CHANGED
@@ -275,10 +275,6 @@ _RUNNER_FIELDS = (
275
275
  *("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
276
276
  *("python_file_uri", "job_name", "auth", "env_parameters"),
277
277
  )
278
- _PYTHON_RUNNER_FIELDS = ( # carried only when set
279
- *("job_scope", "domain_resources", "labels", "annotations", "image_pull_policy"),
280
- *("security_context", "node_selector", "tolerations", "backoff_limit", "env_from"),
281
- )
282
278
 
283
279
 
284
280
  # Rust `runners_contract`: the fields each profile runner type carries into the manifest
@@ -303,9 +299,10 @@ def _runners(profile: ProfileConfig | None) -> Json:
303
299
  v = dict(sorted(cast(dict[str, str], v).items()))
304
300
  runner[k] = v
305
301
  if kind == "kubernetes_job":
306
- for k in _PYTHON_RUNNER_FIELDS:
307
- if (v := profile.runner.get(k)) is not None:
308
- runner[k] = v
302
+ # `job_scope`, `domain_resources` and the pod options (#1065), only when set
303
+ runner |= {
304
+ k: v for k, v in profile.runner.items() if k not in runner and v is not None
305
+ }
309
306
  if kind == "databricks_job":
310
307
  runner["job_name"] = runner["job_name"] or "floe-{domain}-{env}"
311
308
  return {"default": "default", "definitions": {"default": runner}}
floe/run/entity.py CHANGED
@@ -7,7 +7,7 @@ import time
7
7
  from collections.abc import Callable, Generator, Iterable, Iterator
8
8
  from contextlib import closing, suppress
9
9
  from dataclasses import dataclass, field, replace
10
- from datetime import datetime
10
+ from datetime import date, datetime
11
11
  from functools import partial
12
12
  from itertools import chain
13
13
  from pathlib import Path
@@ -93,6 +93,7 @@ from floe.storage.partitioning import (
93
93
  advance,
94
94
  in_load_order,
95
95
  listing_start,
96
+ partition_date,
96
97
  partition_of,
97
98
  source_root,
98
99
  window,
@@ -313,7 +314,11 @@ def _with_archived(e: EntityConfig, archive: tuple[str, StorageClient], source:
313
314
  source()
314
315
  ) # nothing archived: the source's "no input files matched" stands
315
316
  if p is not None: # D29: the archive keeps the partition sub-paths
316
- old = in_load_order(p, e.name, f"{base}/{e.name}", old, archived=True)
317
+ # #939: D28 orders a day's batches by the source's modification time, which the copy's own does not keep
318
+ kept = [replace(f, source_mtime=client.source_mtime(f.source_uri) or f.source_mtime) for f in old.files] # fmt: skip
319
+ old = in_load_order(
320
+ p, e.name, f"{base}/{e.name}", replace(old, files=kept), archived=True
321
+ )
317
322
  try:
318
323
  new = source()
319
324
  except NoInputsError: # no new source file: rebuild from the archive alone
@@ -634,6 +639,7 @@ class Incremental:
634
639
  # the file reports, set once the sink step is done (#915)
635
640
  self.committed: list[dict[str, Any]] | None = None
636
641
  self.advance: Callable[[EntityState, list[str]], None] | None = None # D30
642
+ self.dated: Callable[[str], date | None] | None = None # #949: an input's partition date # fmt: skip
637
643
  self._stop = threading.Event()
638
644
  self._thread: threading.Thread | None = None
639
645
 
@@ -644,9 +650,11 @@ class Incremental:
644
650
  return inputs, []
645
651
  self.uri, self.client = state_uri(e, self.config_dir, self.storages)
646
652
  if self.full_refresh:
647
- self._hold(claim_all(self.client, self.uri, e.name, self.run_id, inputs))
653
+ # #949: `expired` holds the claims it replaced, which `release` restores if the refresh wrote nothing
654
+ claimed, self.expired = claim_all(self.client, self.uri, e.name, self.run_id, inputs) # fmt: skip
655
+ self._hold(claimed)
648
656
  return inputs, []
649
- got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge")) # fmt: skip
657
+ got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge"), self.dated) # fmt: skip
650
658
  reports = skipped_reports(e, got.already_processed, got.active_claims)
651
659
  _emit_skipped(emit, e.name, reports)
652
660
  self.expired = got.expired
@@ -716,10 +724,17 @@ class Incremental:
716
724
  return claimed
717
725
 
718
726
  def release(self) -> None:
719
- """The dead runs' claims this run took over go back: the next run checks them again (#545)."""
727
+ """The dead runs' claims this run took over go back: the next run checks them again (#545).
728
+
729
+ #949: a full refresh puts back the claims it replaced only if it wrote nothing, as its overwrite drops their rows
730
+ and the retry guard would find their run's snapshot behind it.
731
+ """
720
732
  if claimed := self._end():
733
+ restore = (
734
+ {} if self.full_refresh and self.committed is not None else self.expired
735
+ )
721
736
  # D30: an older day's released file stays listed for a retry
722
- release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
737
+ release(self.client, self.uri, self.run_id, claimed, restore, self.advance is not None and not self.full_refresh) # fmt: skip
723
738
 
724
739
  def finish(self, files: list[dict[str, Any]]) -> None:
725
740
  """Promote unless the entity failed (Rust commits on success, warnings and rejected).
@@ -787,12 +802,18 @@ def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig
787
802
  after = listing_start(p, start) if p and start else None
788
803
 
789
804
  def source() -> ResolvedInputs:
790
- inputs = resolve_inputs(
791
- config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
792
- src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
793
- recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
794
- after=f"{root}/{after}" if after else "",
795
- ) # fmt: skip
805
+ try:
806
+ inputs = resolve_inputs(
807
+ config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
808
+ src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
809
+ recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
810
+ after=f"{root}/{after}" if after else "",
811
+ ) # fmt: skip
812
+ except NoInputsError:
813
+ # #1139: an empty incremental source has nothing pending; a full refresh still fails (a broken extraction)
814
+ if entity.incremental_mode != "file" or full_refresh:
815
+ raise
816
+ return ResolvedInputs([], [], "directory")
796
817
  if not p:
797
818
  return inputs
798
819
  inputs = in_load_order(p, entity.name, root, inputs, start=start)
@@ -922,6 +943,7 @@ def _run_entity(
922
943
  if entity.source.partitioning and entity.incremental_mode == "file":
923
944
  dated = entity.source.partitioning, entity.name, source_root(source_uri)
924
945
  inc.advance = partial(advance, *dated)
946
+ inc.dated = partial(partition_date, *dated)
925
947
  archive = plan.archive # a full refresh lists it too
926
948
  plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
927
949
  # D34 (#931): same store only (a bucket name repeats across endpoints); before the claim, so a refusal leaves nothing to release
floe/state.py CHANGED
@@ -6,7 +6,7 @@ import random
6
6
  import time
7
7
  from collections.abc import Callable
8
8
  from dataclasses import asdict, dataclass
9
- from datetime import UTC, datetime, timedelta
9
+ from datetime import UTC, date, datetime, timedelta
10
10
  from typing import NamedTuple
11
11
 
12
12
  from floe.errors import FloeConfigError
@@ -44,6 +44,8 @@ class EntityState:
44
44
  files: dict[str, FileState]
45
45
  claims: dict[str, Claim]
46
46
  watermark: str | None = None # D30: the latest partition date ingested (ISO)
47
+ # #949: `files` holds no entry dated before it (ISO); only a full refresh lists before it
48
+ pruned_before: str | None = None
47
49
 
48
50
 
49
51
  class Loaded(NamedTuple):
@@ -74,6 +76,8 @@ def dump_state(state: EntityState) -> bytes:
74
76
  }
75
77
  if state.watermark: # D30: additive, absent without partitioning
76
78
  doc["watermark"] = state.watermark
79
+ if state.pruned_before: # #949: additive too
80
+ doc["pruned_before"] = state.pruned_before
77
81
  return json.dumps(doc, indent=2, ensure_ascii=False).encode()
78
82
 
79
83
 
@@ -89,6 +93,7 @@ def parse_state(entity: str, body: bytes) -> EntityState:
89
93
  if schema != SCHEMA_V1
90
94
  else {},
91
95
  doc.get("watermark"),
96
+ doc.get("pruned_before"),
92
97
  )
93
98
  except (ValueError, KeyError, TypeError, AttributeError) as e:
94
99
  raise FloeConfigError(f"entity.name={entity} invalid state file: {e}") from e
@@ -96,6 +101,13 @@ def parse_state(entity: str, body: bytes) -> EntityState:
96
101
  raise FloeConfigError(
97
102
  f"entity.name={entity} state schema mismatch: expected {SCHEMA_V1} or {SCHEMA_V2}, got {schema}"
98
103
  )
104
+ if state.pruned_before is not None: # #949: compared as an ISO date string
105
+ try:
106
+ date.fromisoformat(state.pruned_before)
107
+ except (ValueError, TypeError) as e:
108
+ raise FloeConfigError(
109
+ f"entity.name={entity} invalid state file: pruned_before: {e}"
110
+ ) from e
99
111
  if state.entity != entity:
100
112
  raise FloeConfigError(
101
113
  f"entity.name={entity} state entity mismatch: expected {entity}, got {state.entity}"
@@ -138,8 +150,13 @@ def claim_inputs(
138
150
  run_id: str,
139
151
  inputs: list[InputFile],
140
152
  barrier: bool = False,
153
+ dated: Callable[[str], date | None] | None = None,
141
154
  ) -> ClaimOutcome:
142
- """`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`."""
155
+ """`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`.
156
+
157
+ `dated` (#949): an input's partition date; an unclaimed one dated before `pruned_before` is dropped, as a later
158
+ window would not list it (a run that listed before another pruned would re-ingest it).
159
+ """
143
160
  if not inputs:
144
161
  return ClaimOutcome([], None, [], [], {})
145
162
  for attempt in range(CAS_RETRIES):
@@ -157,6 +174,10 @@ def claim_inputs(
157
174
  processed: list[tuple[InputFile, FileState]] = []
158
175
  acquired_at, expires_at = _now(), _now(CLAIM_TTL_SECONDS)
159
176
  for f in inputs:
177
+ day = dated(f.source_uri) if dated and state.pruned_before else None
178
+ # a claimed one is a retry: the window is held back to it
179
+ if day and day.isoformat() < (state.pruned_before or "") and f.source_uri not in expired and f.source_uri not in state.claims: # fmt: skip
180
+ continue
160
181
  if f.source_uri in state.files:
161
182
  processed.append((f, state.files[f.source_uri]))
162
183
  elif blocked or f.source_uri in state.claims:
@@ -186,8 +207,11 @@ def claim_inputs(
186
207
 
187
208
  def claim_all(
188
209
  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`)."""
210
+ ) -> tuple[Loaded, dict[str, Claim]]:
211
+ """Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`).
212
+
213
+ Also the claims it replaced, by URI, for `release` to restore if the refresh fails (#949).
214
+ """
191
215
  for attempt in range(CAS_RETRIES):
192
216
  if attempt:
193
217
  _backoff(attempt - 1)
@@ -197,11 +221,13 @@ def claim_all(
197
221
  f.source_uri: Claim(run_id, acquired_at, expires_at, f.source_size, f.source_mtime)
198
222
  for f in inputs
199
223
  } # 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
224
+ # D30: no watermark until the refresh promotes; #949: `pruned_before` stays, so after a failed one the next run
225
+ # lists from it, never the pruned days
226
+ fresh = Loaded(EntityState(entity, acquired_at, loaded.state.files, claims, None, loaded.state.pruned_before), loaded.version) # fmt: skip
202
227
  version = _persist(client, uri, fresh)
203
228
  if version is not None:
204
- return Loaded(fresh.state, version)
229
+ replaced = {u: c for u, c in loaded.state.claims.items() if u in claims}
230
+ return Loaded(fresh.state, version), replaced
205
231
  raise FloeConfigError(
206
232
  f"entity.name={entity} full-refresh state write conflicted after {CAS_RETRIES} retries"
207
233
  )
@@ -225,7 +251,7 @@ def _mutate(
225
251
  )
226
252
  mutate(loaded.state, ours)
227
253
  state = loaded.state
228
- if state.files or state.claims or state.watermark:
254
+ if state.files or state.claims or state.watermark or state.pruned_before:
229
255
  done = _persist(client, uri, loaded) is not None
230
256
  else: # Rust deletes an emptied state
231
257
  done = client.delete_conditional(uri, loaded.version)
floe/storage/archive.py CHANGED
@@ -21,7 +21,7 @@ def archive_input_file(
21
21
  f"{entity_name}/{subdir}" if subdir else entity_name, input_file.source_name, run_id, input_file.source_uri
22
22
  ) # fmt: skip
23
23
  dest_uri = join_relative(relative)
24
- client.copy(input_file.source_uri, dest_uri)
24
+ client.copy(input_file.source_uri, dest_uri, input_file.source_mtime)
25
25
  try:
26
26
  client.delete(input_file.source_uri)
27
27
  except FloeError as e:
floe/storage/local.py CHANGED
@@ -96,7 +96,8 @@ class LocalClient:
96
96
  key=lambda r: r.uri,
97
97
  )
98
98
 
99
- def copy(self, src_uri: str, dst_uri: str) -> None:
99
+ def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
100
+ """`source_mtime` is not kept: a local copy's nanosecond mtime already follows the archive (load) order (#939)."""
100
101
  src, dst = _path(src_uri), _path(dst_uri)
101
102
  try:
102
103
  dst.parent.mkdir(parents=True, exist_ok=True)
@@ -109,6 +110,9 @@ class LocalClient:
109
110
  except OSError as e:
110
111
  raise FloeStorageError(f"local copy failed from {src} to {dst}: {e}") from e
111
112
 
113
+ def source_mtime(self, uri: str) -> str | None:
114
+ return None
115
+
112
116
  def write(self, uri: str, body: bytes) -> None:
113
117
  path = _path(uri)
114
118
  try:
@@ -76,7 +76,7 @@ def _day(when: date | datetime) -> date:
76
76
  return when.date() if isinstance(when, datetime) else when
77
77
 
78
78
 
79
- def _dated(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None:
79
+ def partition_date(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None: # fmt: skip
80
80
  """The partition date of a state entry; None for one outside the pattern (another source path)."""
81
81
  try:
82
82
  return _day(partition_of(p, entity, root, uri).when)
@@ -89,10 +89,12 @@ def window(p: SourcePartitioning, entity: str, root: str, state: EntityState | N
89
89
 
90
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
91
  """
92
- if state is None or state.watermark is None:
92
+ if state is None or not (state.watermark or state.pruned_before):
93
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
94
+ # #949: never before `pruned_before`, whatever `lookback_days` became
95
+ floor = date.fromisoformat(state.pruned_before or date.min.isoformat())
96
+ start = max(floor, date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)) if state.watermark else floor # fmt: skip
97
+ return min([start, *(d for u in state.claims if (d := partition_date(p, entity, root, u)))]) # fmt: skip
96
98
 
97
99
 
98
100
  def listing_start(p: SourcePartitioning, start: date) -> str | None:
@@ -107,10 +109,18 @@ def listing_start(p: SourcePartitioning, start: date) -> str | None:
107
109
 
108
110
 
109
111
  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`).
112
+ """D30, on promote: the watermark moves to the latest date promoted, never back.
111
113
 
112
114
  Without one (a first run, or after a failed full refresh), every file in the state counts.
115
+ #949: `pruned_before` moves to the window start, never back nor past a claim's date, and `files` drops the entries
116
+ dated before it, in the same state write.
113
117
  """
114
118
  known = promoted if state.watermark else [*promoted, *state.files]
115
- days = [d.isoformat() for u in known if (d := _dated(p, entity, root, u))]
119
+ days = [d.isoformat() for u in known if (d := partition_date(p, entity, root, u))]
116
120
  state.watermark = max([state.watermark or "", *days]) or None
121
+ if state.watermark is None:
122
+ return
123
+ start = date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)
124
+ held = [d for u in state.claims if (d := partition_date(p, entity, root, u))]
125
+ state.pruned_before = max(state.pruned_before or "", min([start, *held]).isoformat()) # fmt: skip
126
+ state.files = {u: f for u, f in state.files.items() if (d := partition_date(p, entity, root, u)) is None or d.isoformat() >= state.pruned_before} # fmt: skip
floe/storage/s3.py CHANGED
@@ -4,6 +4,7 @@ import os
4
4
  import shutil
5
5
  from datetime import UTC, datetime
6
6
  from pathlib import Path
7
+ from typing import Any
7
8
 
8
9
  import boto3
9
10
  from botocore.config import Config
@@ -15,6 +16,17 @@ from floe.storage.types import ObjectRef, StoredObject
15
16
 
16
17
  _CONFLICT = ("PreconditionFailed", "ConditionalRequestConflict", "412", "409")
17
18
  _NOT_FOUND = ("NoSuchKey", "NotFound", "404")
19
+ # #939: an archive copy's `LastModified` is its copy time, to the second; this keeps the source's, for D28 replay order
20
+ _SOURCE_MTIME = "floe-source-mtime"
21
+ # the headers a `MetadataDirective=REPLACE` copy would otherwise drop
22
+ _KEPT = (
23
+ "ContentType",
24
+ "ContentEncoding",
25
+ "ContentDisposition",
26
+ "ContentLanguage",
27
+ "CacheControl",
28
+ "Expires",
29
+ )
18
30
 
19
31
 
20
32
  def resolve_path_style_access(configured: bool | None) -> bool:
@@ -117,15 +129,33 @@ class S3Client:
117
129
  except (ClientError, BotoCoreError) as e:
118
130
  raise FloeStorageError(f"s3 put object failed: {e}") from e
119
131
 
120
- def copy(self, src_uri: str, dst_uri: str) -> None:
132
+ def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
133
+ """`source_mtime` goes to the copy's `x-amz-meta-floe-source-mtime`, with the source's metadata and headers."""
121
134
  (sb, sk), (db, dk) = parse_s3_uri(src_uri), parse_s3_uri(dst_uri)
122
135
  try:
136
+ extra: dict[str, Any] = {}
137
+ if source_mtime:
138
+ head = self._client.head_object(Bucket=sb, Key=sk)
139
+ meta = {**head.get("Metadata", {}), _SOURCE_MTIME: source_mtime}
140
+ extra = {k: head[k] for k in _KEPT if k in head} | {"Metadata": meta, "MetadataDirective": "REPLACE"} # fmt: skip
123
141
  self._client.copy_object(
124
- Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}
142
+ Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}, **extra
125
143
  )
126
144
  except (ClientError, BotoCoreError) as e:
127
145
  raise FloeStorageError(f"s3 copy object failed: {e}") from e
128
146
 
147
+ def source_mtime(self, uri: str) -> str | None:
148
+ """#939: the source modification time an archive copy keeps; None on a copy made before it was kept."""
149
+ bucket, key = parse_s3_uri(uri)
150
+ try:
151
+ meta = self._client.head_object(Bucket=bucket, Key=key).get("Metadata", {})
152
+ # a Go server (MinIO, SeaweedFS) may send the header canonicalised: `X-Amz-Meta-Floe-Source-Mtime`
153
+ return next(
154
+ (v for k, v in meta.items() if k.lower() == _SOURCE_MTIME), None
155
+ )
156
+ except (ClientError, BotoCoreError) as e:
157
+ raise FloeStorageError(f"s3 head object failed for {uri}: {e}") from e
158
+
129
159
  def delete(self, uri: str) -> None:
130
160
  bucket, key = parse_s3_uri(uri)
131
161
  try:
floe/storage/types.py CHANGED
@@ -20,7 +20,10 @@ class StorageClient(Protocol):
20
20
  def resolve_uri(self, path: str) -> str: ...
21
21
  def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]: ...
22
22
  def write(self, uri: str, body: bytes) -> None: ...
23
- def copy(self, src_uri: str, dst_uri: str) -> None: ...
23
+ def copy(
24
+ self, src_uri: str, dst_uri: str, source_mtime: str | None = None
25
+ ) -> None: ...
26
+ def source_mtime(self, uri: str) -> str | None: ...
24
27
  def delete(self, uri: str) -> None: ...
25
28
  def read(self, uri: str) -> StoredObject | None: ...
26
29
  def write_conditional(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: floe-python
3
- Version: 0.8.9
3
+ Version: 0.9.0
4
4
  Summary: Floe: a data-contract runtime for file ingestion
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -6,11 +6,11 @@ floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
6
6
  floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
7
7
  floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
8
8
  floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
9
- floe/manifest.py,sha256=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
9
+ floe/manifest.py,sha256=TDjSOrDXoH59lcYQ_FGIGCEg2JZ0EKGMw4cVtd1kzCA,20312
10
10
  floe/manifest_run.py,sha256=8rkHX2RuxHyp02NI2NLBsU6xq6m5yebR0eoYETV_7Tw,12591
11
11
  floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
12
12
  floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
13
+ floe/state.py,sha256=H7BRVmpw2zfwPR9FiLqbHHV53hoxHNvXGTbT9jHonRU,12635
14
14
  floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
15
15
  floe/checks/mismatch.py,sha256=RNqRL1nihH6Y83RI-dxPXf0WTWZccXq6mnR4m6V0IIo,5827
16
16
  floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
@@ -20,7 +20,7 @@ floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
21
21
  floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
22
22
  floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
23
- floe/config/profile.py,sha256=4imKVdZ-Ehbj71OA3K4PrShE-gOYuicMDnTD-QHIkA0,16552
23
+ floe/config/profile.py,sha256=d_Lw8SGdO1h4of7Cl0fraAfB3W5sMYZvECguxgsdIGA,17253
24
24
  floe/config/project.py,sha256=hUsnjbNAEiiy_iVJYA1IDquZ4OY4OF7JpNaEVJ_ocCg,14221
25
25
  floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
26
26
  floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
@@ -28,7 +28,7 @@ floe/config/validate.py,sha256=7tyofwoTBL-BBCNxnmkXeSOybwmMNngeEcj8zviRyLs,36176
28
28
  floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
29
29
  floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
30
30
  floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
31
- floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
31
+ floe/iceberg/compact.py,sha256=te8QLzg-iU_Y7Z9z0BSqMf_vjuV6CzQGV_3OeutSaZg,7237
32
32
  floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
33
33
  floe/iceberg/sink.py,sha256=vdkgLjqvWH7opOrfc-147xCMQhLnxRjUzLjHLchHlAM,12043
34
34
  floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
@@ -46,23 +46,23 @@ floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
46
46
  floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
47
47
  floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
48
48
  floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
49
- floe/run/entity.py,sha256=2NxCKNVUn0v2Oat4FslZfNjWAu3YhGKnSks3NRwkexU,49764
49
+ floe/run/entity.py,sha256=MlNTdUM8dcZxubB_0FkjfSGbSz2TMcaBBm9Q9V4P7Rk,51072
50
50
  floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
51
51
  floe/run/parallel.py,sha256=lKBW9NOpOu6KFOcEo-RQ56ukEOKHXPPoLI3o6gmlY_g,3435
52
52
  floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
- floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
53
+ floe/storage/archive.py,sha256=JQKizd5PcWWhOFr4nxnkp2OT8Tl1h-Bu28EXWWj-gMc,1126
54
54
  floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
55
- floe/storage/local.py,sha256=UiGFYEY33j45CQspWzRyJBW0HC2Nk9_zG--wL-aUuJI,6388
56
- floe/storage/partitioning.py,sha256=MG8RJDrwgZhucTswlXC2RwCmzrYIoOE6SH_aBM11KW8,6178
55
+ floe/storage/local.py,sha256=uz7LrtjItFKVtnzna3b06hv5Cv3ov1l79Y2NmxUJ-pg,6617
56
+ floe/storage/partitioning.py,sha256=kheJLDtRyiizgsCiv_8DChAv9Rc5dOxF0q8_yVZEjFE,7057
57
57
  floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
58
58
  floe/storage/prefetch.py,sha256=b-_be13xkJIw3LVndOb6SouTd1Pf8uZwG6Wy0Nk1pr8,1801
59
59
  floe/storage/resolve.py,sha256=0hGSdU_p10j9vzET25Kcnd-QF6H7uN-hzpo_SC-DV_c,3154
60
- floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
61
- floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
60
+ floe/storage/s3.py,sha256=jG9GWCSSl1NtFus8C_vCe1wtHuKKZs1Jwz0i3jFFT1M,9074
61
+ floe/storage/types.py,sha256=sb-XQr0f7f4JgmfuAFRjHaaGmn63T7v09AvvScXGOTg,1096
62
62
  floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
63
  floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
64
- floe_python-0.8.9.dist-info/METADATA,sha256=jwfiIw93AklPynvV_-ABBNgnNCZjBArFMK4APsKu9Bk,442
65
- floe_python-0.8.9.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
- floe_python-0.8.9.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
- floe_python-0.8.9.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
- floe_python-0.8.9.dist-info/RECORD,,
64
+ floe_python-0.9.0.dist-info/METADATA,sha256=XrdM3vzJi9FKxGWCeybRfWpBKMYvvnZAMt-zD2AZTKA,442
65
+ floe_python-0.9.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
66
+ floe_python-0.9.0.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
67
+ floe_python-0.9.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
68
+ floe_python-0.9.0.dist-info/RECORD,,