someone-in-the-world Claude Sonnet 4.6 commited on
Commit
2f63460
·
1 Parent(s): bc828ab

Batch log uploads to avoid HF 256-commits/hour rate limit

Browse files

Replaces per-inference commit (prune + upload = 2 commits) with a
LogUploader class that queues parquet files locally and flushes them
in a single batched commit every LOG_BATCH_INTERVAL seconds (default 60).
At full load this reduces commit rate from O(requests) to ~1/minute.

Also splits _build_commit_ops into _build_add_ops and _build_delete_ops
so each helper has a single responsibility, and updates tests accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +9 -2
  2. logging_utils.py +112 -96
  3. tests/test_logging_utils.py +59 -68
app.py CHANGED
@@ -14,7 +14,14 @@ import json
14
  import html as html_lib
15
  from io import BytesIO
16
  from PIL import Image
17
- from logging_utils import log_inference
 
 
 
 
 
 
 
18
 
19
  MAX_SEED = np.iinfo(np.int32).max
20
  LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
@@ -295,7 +302,7 @@ def _resolve_seed(seed: int, randomize_seed: bool) -> int:
295
  def _spawn_log(pil_images, result_image, prompt, seed, steps, guidance_scale,
296
  width, height, duration, success, error=""):
297
  threading.Thread(
298
- target=log_inference,
299
  args=(pil_images, result_image, prompt, seed, steps, guidance_scale,
300
  width, height, duration, success, error),
301
  daemon=True,
 
14
  import html as html_lib
15
  from io import BytesIO
16
  from PIL import Image
17
+ from logging_utils import LogUploader
18
+
19
+ _log_uploader = LogUploader(
20
+ token=os.environ.get("HF_TOKEN"),
21
+ repo_id=os.environ.get("LOG_DATASET_REPO"),
22
+ max_files=int(os.environ.get("LOG_MAX_FILES", "5000")),
23
+ batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
24
+ )
25
 
26
  MAX_SEED = np.iinfo(np.int32).max
27
  LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
 
302
  def _spawn_log(pil_images, result_image, prompt, seed, steps, guidance_scale,
303
  width, height, duration, success, error=""):
304
  threading.Thread(
305
+ target=_log_uploader.log_inference,
306
  args=(pil_images, result_image, prompt, seed, steps, guidance_scale,
307
  width, height, duration, success, error),
308
  daemon=True,
logging_utils.py CHANGED
@@ -1,12 +1,10 @@
1
  import os
2
  import uuid
 
 
3
  from io import BytesIO
4
- from datetime import datetime, timezone, timedelta
5
- from huggingface_hub import hf_hub_download, CommitOperationDelete
6
-
7
- HF_TOKEN = os.environ.get("HF_TOKEN")
8
- DATASET_REPO = os.environ.get("LOG_DATASET_REPO")
9
- MAX_LOG_FILES = int(os.environ.get("LOG_MAX_FILES", "5000"))
10
 
11
 
12
  def _img_to_jpeg(img, quality=85):
@@ -74,40 +72,51 @@ def _build_table(pil_inputs, output_pil, prompt, seed, steps, guidance_scale,
74
  }, schema=schema)
75
 
76
 
77
- def _upload_parquet(api, repo_id, table, path_in_repo):
78
  import tempfile
79
  import pyarrow.parquet as pq
80
-
81
- tmp_path = None
82
- try:
83
- with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
84
- tmp_path = tmp.name
85
- pq.write_table(table, tmp_path)
86
- print(f"[log] uploading {path_in_repo} ({os.path.getsize(tmp_path)//1024}KB)")
87
- api.upload_file(
88
- path_or_fileobj=tmp_path, path_in_repo=path_in_repo,
89
- repo_id=repo_id, repo_type="dataset",
90
- )
91
- print(f"[log] upload done — {repo_id}/{path_in_repo}")
92
- finally:
93
- if tmp_path:
94
- try:
95
- os.unlink(tmp_path)
96
- except Exception as e:
97
- print(f"[log] failed to delete temp file {tmp_path}: {e}")
98
 
99
 
100
  def _make_path(now, uid):
101
  return f"data/{now.strftime('%Y-%m-%d-%H%M%S')}-{uid}.parquet"
102
 
103
 
104
- def _file_date(path):
105
- return os.path.basename(path)[:10]
 
 
 
 
 
 
 
 
 
 
106
 
 
 
 
 
 
 
107
 
108
- def _maybe_squash_history(api, repo_id, now):
 
 
 
 
 
 
 
 
 
109
  marker = "metadata/last_squash.txt"
110
- today = now.strftime("%Y-%m-%d")
111
  try:
112
  try:
113
  local = hf_hub_download(repo_id=repo_id, filename=marker,
@@ -116,78 +125,85 @@ def _maybe_squash_history(api, repo_id, now):
116
  return
117
  except Exception as e:
118
  print(f"[log] squash marker not found ({e}), proceeding with squash")
119
-
120
  api.super_squash_history(repo_id=repo_id, repo_type="dataset")
 
 
121
  print(f"[log] squashed history for {repo_id}")
122
-
123
- api.upload_file(
124
- path_or_fileobj=today.encode(), path_in_repo=marker,
125
- repo_id=repo_id, repo_type="dataset",
126
- )
127
- print(f"[log] updated squash marker: {today}")
128
  except Exception as e:
129
  print(f"[log] squash warning: {e}")
130
 
131
 
132
- def _prune_old_files(api, repo_id, keep_count):
133
- if keep_count <= 0:
134
- return
135
- try:
136
- all_files = sorted(
137
- f.path
138
- for f in api.list_repo_tree(repo_id, repo_type="dataset", path_in_repo="data")
139
- if f.path.endswith(".parquet")
140
- )
141
- to_delete = all_files[:-keep_count] if len(all_files) > keep_count else []
142
- if to_delete:
143
- ops = [CommitOperationDelete(path_in_repo=p) for p in to_delete]
144
- api.create_commit(
145
- repo_id=repo_id, repo_type="dataset", operations=ops,
146
- commit_message=f"[log] prune {len(to_delete)} old file(s)",
147
- )
148
- print(f"[log] pruned {len(to_delete)} old file(s)")
149
- except Exception as e:
150
- print(f"[log] prune warning: {e}")
151
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- def log_inference(pil_inputs, output_pil, prompt, seed, steps, guidance_scale,
154
- input_width, input_height, duration_seconds, success, error_message=""):
155
- import time as _time
156
- _t0 = _time.perf_counter()
157
- if not HF_TOKEN or not DATASET_REPO:
158
- print(f"[log] skipped — HF_TOKEN={'set' if HF_TOKEN else 'missing'}, DATASET_REPO={'set' if DATASET_REPO else 'missing'}")
159
- return
160
- try:
161
  from huggingface_hub import HfApi
162
-
163
- now = datetime.now(timezone.utc)
164
-
165
- _t1 = _time.perf_counter()
166
- table = _build_table(pil_inputs, output_pil, prompt, seed, steps, guidance_scale,
167
- input_width, input_height, duration_seconds, success, error_message, now)
168
- print(f"[log] build_table: {_time.perf_counter() - _t1:.3f}s")
169
-
170
- uid = uuid.uuid4().hex[:8]
171
- path_in_repo = _make_path(now, uid)
172
-
173
- _t2 = _time.perf_counter()
174
- api = HfApi(token=HF_TOKEN)
175
- api.create_repo(repo_id=DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
176
- print(f"[log] create_repo: {_time.perf_counter() - _t2:.3f}s")
177
-
178
- _t3 = _time.perf_counter()
179
- _prune_old_files(api, DATASET_REPO, MAX_LOG_FILES)
180
- print(f"[log] prune_old_files: {_time.perf_counter() - _t3:.3f}s")
181
-
182
- _t4 = _time.perf_counter()
183
- _upload_parquet(api, DATASET_REPO, table, path_in_repo)
184
- print(f"[log] upload_parquet: {_time.perf_counter() - _t4:.3f}s")
185
-
186
- _t5 = _time.perf_counter()
187
- _maybe_squash_history(api, DATASET_REPO, now)
188
- print(f"[log] squash_history: {_time.perf_counter() - _t5:.3f}s")
189
- except Exception as log_err:
190
- import traceback as _tb
191
- print(f"[log] WARNING: {log_err}\n{_tb.format_exc()}")
192
- finally:
193
- print(f"[log] log_inference total: {_time.perf_counter() - _t0:.3f}s")
 
1
  import os
2
  import uuid
3
+ import threading
4
+ import time as _time
5
  from io import BytesIO
6
+ from datetime import datetime, timezone
7
+ from huggingface_hub import hf_hub_download, CommitOperationAdd, CommitOperationDelete
 
 
 
 
8
 
9
 
10
  def _img_to_jpeg(img, quality=85):
 
72
  }, schema=schema)
73
 
74
 
75
+ def _write_parquet(table):
76
  import tempfile
77
  import pyarrow.parquet as pq
78
+ with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
79
+ path = tmp.name
80
+ pq.write_table(table, path)
81
+ return path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  def _make_path(now, uid):
85
  return f"data/{now.strftime('%Y-%m-%d-%H%M%S')}-{uid}.parquet"
86
 
87
 
88
+ def _list_existing_files(api, repo_id):
89
+ return sorted(
90
+ f.path
91
+ for f in api.list_repo_tree(repo_id, repo_type="dataset", path_in_repo="data")
92
+ if f.path.endswith(".parquet")
93
+ )
94
+
95
+
96
+ def _build_add_ops(batch):
97
+ return [CommitOperationAdd(path_in_repo=p, path_or_fileobj=local)
98
+ for p, local in batch]
99
+
100
 
101
+ def _build_delete_ops(existing_files, n_new, max_files):
102
+ total_after = len(existing_files) + n_new
103
+ if max_files <= 0 or total_after <= max_files:
104
+ return []
105
+ n_delete = total_after - max_files
106
+ return [CommitOperationDelete(path_in_repo=p) for p in existing_files[:n_delete]]
107
 
108
+
109
+ def _delete_temp_files(batch):
110
+ for _, local in batch:
111
+ try:
112
+ os.unlink(local)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def _squash_if_needed(api, repo_id):
118
  marker = "metadata/last_squash.txt"
119
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
120
  try:
121
  try:
122
  local = hf_hub_download(repo_id=repo_id, filename=marker,
 
125
  return
126
  except Exception as e:
127
  print(f"[log] squash marker not found ({e}), proceeding with squash")
 
128
  api.super_squash_history(repo_id=repo_id, repo_type="dataset")
129
+ api.upload_file(path_or_fileobj=today.encode(), path_in_repo=marker,
130
+ repo_id=repo_id, repo_type="dataset")
131
  print(f"[log] squashed history for {repo_id}")
 
 
 
 
 
 
132
  except Exception as e:
133
  print(f"[log] squash warning: {e}")
134
 
135
 
136
+ class LogUploader:
137
+ def __init__(self, token, repo_id, max_files=5000, batch_interval=60):
138
+ self._token = token
139
+ self._repo_id = repo_id
140
+ self._max_files = max_files
141
+ self._batch_interval = batch_interval
142
+ self._pending = []
143
+ self._lock = threading.Lock()
144
+ if token and repo_id:
145
+ threading.Thread(target=self._loop, daemon=True, name="log-uploader").start()
146
+
147
+ def log_inference(self, pil_inputs, output_pil, prompt, seed, steps, guidance_scale,
148
+ input_width, input_height, duration_seconds, success, error_message=""):
149
+ if not self._token or not self._repo_id:
150
+ print(f"[log] skipped — token={'set' if self._token else 'missing'}, repo={'set' if self._repo_id else 'missing'}")
151
+ return
152
+ t0 = _time.perf_counter()
153
+ try:
154
+ now = datetime.now(timezone.utc)
155
+ table = _build_table(pil_inputs, output_pil, prompt, seed, steps, guidance_scale,
156
+ input_width, input_height, duration_seconds, success, error_message, now)
157
+ local_path = _write_parquet(table)
158
+ path_in_repo = _make_path(now, uuid.uuid4().hex[:8])
159
+ self._enqueue(path_in_repo, local_path)
160
+ print(f"[log] queued {path_in_repo} (pending={len(self._pending)})")
161
+ except Exception as e:
162
+ import traceback as _tb
163
+ print(f"[log] WARNING: {e}\n{_tb.format_exc()}")
164
+ print(f"[log] log_inference total: {_time.perf_counter() - t0:.3f}s")
165
+
166
+ def _enqueue(self, path_in_repo, local_path):
167
+ with self._lock:
168
+ self._pending.append((path_in_repo, local_path))
169
+
170
+ def _drain(self):
171
+ with self._lock:
172
+ batch = self._pending[:]
173
+ self._pending.clear()
174
+ return batch
175
+
176
+ def _requeue(self, batch):
177
+ with self._lock:
178
+ self._pending[:0] = batch
179
+
180
+ def _loop(self):
181
+ while True:
182
+ _time.sleep(self._batch_interval)
183
+ self._flush()
184
+
185
+ def _flush(self):
186
+ batch = self._drain()
187
+ if not batch:
188
+ return
189
+ try:
190
+ self._commit_batch(batch)
191
+ _delete_temp_files(batch)
192
+ except Exception as e:
193
+ print(f"[log] batch upload warning: {e}")
194
+ self._requeue(batch)
195
 
196
+ def _commit_batch(self, batch):
 
 
 
 
 
 
 
197
  from huggingface_hub import HfApi
198
+ api = HfApi(token=self._token)
199
+ api.create_repo(repo_id=self._repo_id, repo_type="dataset", private=True, exist_ok=True)
200
+ existing = _list_existing_files(api, self._repo_id)
201
+ add_ops = _build_add_ops(batch)
202
+ del_ops = _build_delete_ops(existing, len(batch), self._max_files)
203
+ api.create_commit(
204
+ repo_id=self._repo_id, repo_type="dataset",
205
+ operations=add_ops + del_ops,
206
+ commit_message=f"[log] batch {len(batch)}" + (f", prune {len(del_ops)}" if del_ops else ""),
207
+ )
208
+ print(f"[log] committed {len(batch)} file(s), pruned {len(del_ops)}")
209
+ _squash_if_needed(api, self._repo_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_logging_utils.py CHANGED
@@ -2,104 +2,93 @@ from datetime import datetime, timezone, timedelta
2
  from types import SimpleNamespace
3
  from unittest.mock import MagicMock
4
 
5
- from huggingface_hub import CommitOperationDelete
6
- from logging_utils import _prune_old_files, _file_date, _make_path, _maybe_squash_history
 
 
7
 
8
  NOW = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc)
9
  KEEP_COUNT = 10
10
 
11
 
12
- def _make_api(paths):
13
- api = MagicMock()
14
- api.list_repo_tree.return_value = [SimpleNamespace(path=p) for p in paths]
15
- return api
16
-
17
-
18
  def _file(days_ago):
19
  return _make_path(NOW - timedelta(days=days_ago), "abcd1234")
20
 
21
 
22
- def _deleted_paths(api):
23
- ops = api.create_commit.call_args.kwargs["operations"]
24
- return {op.path_in_repo for op in ops}
25
 
 
 
 
 
 
 
 
 
 
26
 
27
- def test_file_date_matches_make_path():
28
- """Contract: _file_date(_make_path(now, uid)) must return now's date.
29
 
30
- _make_path is the single source of truth for file naming.
31
- _file_date must correctly parse whatever _make_path produces.
32
- If either changes incompatibly, this test breaks immediately.
33
- """
34
- import uuid
35
- path = _make_path(NOW, uuid.uuid4().hex[:8])
36
- assert _file_date(path) == NOW.strftime("%Y-%m-%d")
37
 
38
 
 
 
39
  def test_oldest_files_deleted_when_over_limit():
40
- files = [_file(i) for i in range(12)] # 12 files, oldest = largest days_ago
41
- api = _make_api(files)
42
- _prune_old_files(api, "org/repo", KEEP_COUNT)
43
- assert len(_deleted_paths(api)) == 2
44
- # oldest two files (days_ago=11, days_ago=10) should be deleted
45
- assert _deleted_paths(api) == {_file(11), _file(10)}
46
 
47
 
48
  def test_files_kept_when_under_limit():
49
- api = _make_api([_file(i) for i in range(5)])
50
- _prune_old_files(api, "org/repo", KEEP_COUNT)
51
- api.create_commit.assert_not_called()
52
 
53
 
54
  def test_exactly_at_limit_nothing_deleted():
55
- api = _make_api([_file(i) for i in range(KEEP_COUNT)])
56
- _prune_old_files(api, "org/repo", KEEP_COUNT)
57
- api.create_commit.assert_not_called()
58
 
59
 
60
  def test_one_over_limit_oldest_deleted():
61
- files = [_file(i) for i in range(KEEP_COUNT + 1)]
62
- api = _make_api(files)
63
- _prune_old_files(api, "org/repo", KEEP_COUNT)
64
- assert _deleted_paths(api) == {_file(KEEP_COUNT)}
65
 
66
 
67
- def test_empty_directory_does_nothing():
68
- api = _make_api([])
69
- _prune_old_files(api, "org/repo", KEEP_COUNT)
70
- api.create_commit.assert_not_called()
71
 
72
 
73
- def test_keep_count_zero_skips_pruning():
74
- api = _make_api([_file(0)])
75
- _prune_old_files(api, "org/repo", keep_count=0)
76
- api.list_repo_tree.assert_not_called()
77
- api.create_commit.assert_not_called()
78
 
79
 
80
- def test_non_parquet_files_are_ignored():
81
- # 10 parquet files + 1 non-parquet: total parquet == KEEP_COUNT, nothing deleted
82
- api = _make_api([_file(i) for i in range(KEEP_COUNT)] + ["data/2020-01-01-README.md"])
83
- _prune_old_files(api, "org/repo", KEEP_COUNT)
84
- api.create_commit.assert_not_called()
85
 
86
 
87
- def test_api_error_does_not_raise():
88
- api = MagicMock()
89
- api.list_repo_tree.side_effect = RuntimeError("network error")
90
- _prune_old_files(api, "org/repo", KEEP_COUNT) # must not raise
 
 
91
 
92
 
93
- def test_oldest_files_deleted_correct_order():
94
- # Provide files in non-sorted order; pruning should still delete the oldest
95
- files = [_file(3), _file(11), _file(0), _file(10), _file(1),
96
- _file(2), _file(4), _file(5), _file(6), _file(7), _file(8)] # 11 files
97
- api = _make_api(files)
98
- _prune_old_files(api, "org/repo", KEEP_COUNT)
99
- assert _deleted_paths(api) == {_file(11)}
100
 
101
 
102
- # ── _maybe_squash_history ────────────────────────────────────────────────────
103
 
104
  def _squash_api():
105
  api = MagicMock()
@@ -113,26 +102,28 @@ def test_squash_runs_when_no_marker(monkeypatch):
113
  MagicMock(side_effect=FileNotFoundError("no marker")),
114
  )
115
  api = _squash_api()
116
- _maybe_squash_history(api, "org/repo", NOW)
117
  api.super_squash_history.assert_called_once()
118
  api.upload_file.assert_called_once()
119
 
120
 
121
  def test_squash_skipped_when_marker_is_today(monkeypatch, tmp_path):
 
122
  marker = tmp_path / "last_squash.txt"
123
- marker.write_text(NOW.strftime("%Y-%m-%d"))
124
  monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
125
  api = _squash_api()
126
- _maybe_squash_history(api, "org/repo", NOW)
127
  api.super_squash_history.assert_not_called()
128
 
129
 
130
  def test_squash_runs_when_marker_is_yesterday(monkeypatch, tmp_path):
 
131
  marker = tmp_path / "last_squash.txt"
132
- marker.write_text((NOW - timedelta(days=1)).strftime("%Y-%m-%d"))
133
  monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
134
  api = _squash_api()
135
- _maybe_squash_history(api, "org/repo", NOW)
136
  api.super_squash_history.assert_called_once()
137
 
138
 
@@ -143,4 +134,4 @@ def test_squash_error_does_not_raise(monkeypatch):
143
  )
144
  api = _squash_api()
145
  api.super_squash_history.side_effect = RuntimeError("squash failed")
146
- _maybe_squash_history(api, "org/repo", NOW) # must not raise
 
2
  from types import SimpleNamespace
3
  from unittest.mock import MagicMock
4
 
5
+ from huggingface_hub import CommitOperationAdd, CommitOperationDelete
6
+ from logging_utils import (
7
+ _build_add_ops, _build_delete_ops, _make_path, _squash_if_needed,
8
+ )
9
 
10
  NOW = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc)
11
  KEEP_COUNT = 10
12
 
13
 
 
 
 
 
 
 
14
  def _file(days_ago):
15
  return _make_path(NOW - timedelta(days=days_ago), "abcd1234")
16
 
17
 
18
+ # ── _build_add_ops ────────────────────────────────────────────────────────────
 
 
19
 
20
+ def test_build_add_ops_returns_one_op_per_entry(tmp_path):
21
+ files = [tmp_path / "a.parquet", tmp_path / "b.parquet"]
22
+ for f in files:
23
+ f.write_bytes(b"")
24
+ batch = [("data/a.parquet", str(files[0])), ("data/b.parquet", str(files[1]))]
25
+ ops = _build_add_ops(batch)
26
+ assert len(ops) == 2
27
+ assert all(isinstance(op, CommitOperationAdd) for op in ops)
28
+ assert [op.path_in_repo for op in ops] == ["data/a.parquet", "data/b.parquet"]
29
 
 
 
30
 
31
+ def test_build_add_ops_empty_batch():
32
+ assert _build_add_ops([]) == []
 
 
 
 
 
33
 
34
 
35
+ # ── _build_delete_ops ─────────────────────────────────────────────────────────
36
+
37
  def test_oldest_files_deleted_when_over_limit():
38
+ existing = sorted([_file(i) for i in range(12)]) # 12 files
39
+ ops = _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT)
40
+ deleted = {op.path_in_repo for op in ops}
41
+ assert len(deleted) == 2
42
+ assert deleted == {_file(11), _file(10)}
 
43
 
44
 
45
  def test_files_kept_when_under_limit():
46
+ existing = [_file(i) for i in range(5)]
47
+ assert _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT) == []
 
48
 
49
 
50
  def test_exactly_at_limit_nothing_deleted():
51
+ existing = [_file(i) for i in range(KEEP_COUNT)]
52
+ assert _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT) == []
 
53
 
54
 
55
  def test_one_over_limit_oldest_deleted():
56
+ existing = sorted([_file(i) for i in range(KEEP_COUNT + 1)])
57
+ ops = _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT)
58
+ assert {op.path_in_repo for op in ops} == {_file(KEEP_COUNT)}
 
59
 
60
 
61
+ def test_empty_existing_does_nothing():
62
+ assert _build_delete_ops([], n_new=0, max_files=KEEP_COUNT) == []
 
 
63
 
64
 
65
+ def test_max_files_zero_skips_pruning():
66
+ existing = [_file(0)]
67
+ assert _build_delete_ops(existing, n_new=0, max_files=0) == []
 
 
68
 
69
 
70
+ def test_n_new_counted_toward_total():
71
+ # 8 existing + 4 new = 12 total, need to delete 2
72
+ existing = sorted([_file(i) for i in range(8)])
73
+ ops = _build_delete_ops(existing, n_new=4, max_files=KEEP_COUNT)
74
+ assert len(ops) == 2
75
 
76
 
77
+ def test_oldest_files_deleted_regardless_of_input_order():
78
+ files = [_file(3), _file(11), _file(0), _file(10), _file(1),
79
+ _file(2), _file(4), _file(5), _file(6), _file(7), _file(8)]
80
+ existing = sorted(files) # caller sorts before passing
81
+ ops = _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT)
82
+ assert {op.path_in_repo for op in ops} == {_file(11)}
83
 
84
 
85
+ def test_all_ops_are_delete_type():
86
+ existing = sorted([_file(i) for i in range(12)])
87
+ ops = _build_delete_ops(existing, n_new=0, max_files=KEEP_COUNT)
88
+ assert all(isinstance(op, CommitOperationDelete) for op in ops)
 
 
 
89
 
90
 
91
+ # ── _squash_if_needed ─────────────────────────────────────────────────────────
92
 
93
  def _squash_api():
94
  api = MagicMock()
 
102
  MagicMock(side_effect=FileNotFoundError("no marker")),
103
  )
104
  api = _squash_api()
105
+ _squash_if_needed(api, "org/repo")
106
  api.super_squash_history.assert_called_once()
107
  api.upload_file.assert_called_once()
108
 
109
 
110
  def test_squash_skipped_when_marker_is_today(monkeypatch, tmp_path):
111
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
112
  marker = tmp_path / "last_squash.txt"
113
+ marker.write_text(today)
114
  monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
115
  api = _squash_api()
116
+ _squash_if_needed(api, "org/repo")
117
  api.super_squash_history.assert_not_called()
118
 
119
 
120
  def test_squash_runs_when_marker_is_yesterday(monkeypatch, tmp_path):
121
+ yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
122
  marker = tmp_path / "last_squash.txt"
123
+ marker.write_text(yesterday)
124
  monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
125
  api = _squash_api()
126
+ _squash_if_needed(api, "org/repo")
127
  api.super_squash_history.assert_called_once()
128
 
129
 
 
134
  )
135
  api = _squash_api()
136
  api.super_squash_history.side_effect = RuntimeError("squash failed")
137
+ _squash_if_needed(api, "org/repo") # must not raise