Spaces:
Sleeping
Sleeping
Commit ·
2f63460
1
Parent(s): bc828ab
Batch log uploads to avoid HF 256-commits/hour rate limit
Browse filesReplaces 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>
- app.py +9 -2
- logging_utils.py +112 -96
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 78 |
import tempfile
|
| 79 |
import pyarrow.parquet as pq
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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
|
| 105 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
def
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 31 |
-
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
assert len(
|
| 44 |
-
|
| 45 |
-
assert _deleted_paths(api) == {_file(11), _file(10)}
|
| 46 |
|
| 47 |
|
| 48 |
def test_files_kept_when_under_limit():
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
api.create_commit.assert_not_called()
|
| 52 |
|
| 53 |
|
| 54 |
def test_exactly_at_limit_nothing_deleted():
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
api.create_commit.assert_not_called()
|
| 58 |
|
| 59 |
|
| 60 |
def test_one_over_limit_oldest_deleted():
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
assert _deleted_paths(api) == {_file(KEEP_COUNT)}
|
| 65 |
|
| 66 |
|
| 67 |
-
def
|
| 68 |
-
|
| 69 |
-
_prune_old_files(api, "org/repo", KEEP_COUNT)
|
| 70 |
-
api.create_commit.assert_not_called()
|
| 71 |
|
| 72 |
|
| 73 |
-
def
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
api.list_repo_tree.assert_not_called()
|
| 77 |
-
api.create_commit.assert_not_called()
|
| 78 |
|
| 79 |
|
| 80 |
-
def
|
| 81 |
-
#
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
|
| 86 |
|
| 87 |
-
def
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
api = _make_api(files)
|
| 98 |
-
_prune_old_files(api, "org/repo", KEEP_COUNT)
|
| 99 |
-
assert _deleted_paths(api) == {_file(11)}
|
| 100 |
|
| 101 |
|
| 102 |
-
# ──
|
| 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 |
-
|
| 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(
|
| 124 |
monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
|
| 125 |
api = _squash_api()
|
| 126 |
-
|
| 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(
|
| 133 |
monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
|
| 134 |
api = _squash_api()
|
| 135 |
-
|
| 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 |
-
|
|
|
|
| 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
|