Spaces:
Running on Zero
Running on Zero
Commit Β·
c4cb115
1
Parent(s): 19af080
Add inference logging to HuggingFace dataset
Browse filesLogs each swap (target, sources, result, indices, duration, success/error)
as a Parquet row to a private HF dataset via a daemon thread so it never
blocks the response. Prunes files older than LOG_MAX_DAYS and squashes repo
history once per day. Skips silently when HF_TOKEN / LOG_DATASET_REPO are
unset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py +29 -0
- logging_utils.py +183 -0
- pyproject.toml +24 -0
- requirements.txt +1 -0
- tests/test_app.py +1 -1
- tests/test_logging_utils.py +133 -0
app.py
CHANGED
|
@@ -3,6 +3,7 @@ import spaces # must be first β ZeroGPU requires this before any CUDA package
|
|
| 3 |
import os
|
| 4 |
import gc
|
| 5 |
import time
|
|
|
|
| 6 |
import traceback
|
| 7 |
import json
|
| 8 |
import base64
|
|
@@ -16,6 +17,7 @@ from insightface.app import FaceAnalysis
|
|
| 16 |
from io import BytesIO
|
| 17 |
from PIL import Image
|
| 18 |
from huggingface_hub import hf_hub_download
|
|
|
|
| 19 |
|
| 20 |
# basicsr (gfpgan dep) imports this submodule which was removed in torchvision>=0.17
|
| 21 |
import sys
|
|
@@ -214,6 +216,28 @@ def _enhance(cv2_img, enhancer):
|
|
| 214 |
return enhanced
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
# ββ Public Gradio functions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 218 |
|
| 219 |
def detect_faces(target_b64):
|
|
@@ -279,12 +303,17 @@ def swap_faces(target_b64, selected_json, sources_json):
|
|
| 279 |
duration = time.perf_counter() - t0
|
| 280 |
print(f"[swap] done in {duration:.2f}s", flush=True)
|
| 281 |
|
|
|
|
|
|
|
| 282 |
yield result_pil, ""
|
| 283 |
|
| 284 |
except gr.Error:
|
| 285 |
raise
|
| 286 |
except Exception as e:
|
| 287 |
print(f"[swap] ERROR: {e}\n{traceback.format_exc()}", flush=True)
|
|
|
|
|
|
|
|
|
|
| 288 |
raise
|
| 289 |
finally:
|
| 290 |
gc.collect()
|
|
|
|
| 3 |
import os
|
| 4 |
import gc
|
| 5 |
import time
|
| 6 |
+
import threading
|
| 7 |
import traceback
|
| 8 |
import json
|
| 9 |
import base64
|
|
|
|
| 17 |
from io import BytesIO
|
| 18 |
from PIL import Image
|
| 19 |
from huggingface_hub import hf_hub_download
|
| 20 |
+
from logging_utils import log_inference
|
| 21 |
|
| 22 |
# basicsr (gfpgan dep) imports this submodule which was removed in torchvision>=0.17
|
| 23 |
import sys
|
|
|
|
| 216 |
return enhanced
|
| 217 |
|
| 218 |
|
| 219 |
+
# ββ Logging helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 220 |
+
|
| 221 |
+
def _collect_source_pils(selected_indices, sources):
|
| 222 |
+
pils = []
|
| 223 |
+
for idx in selected_indices:
|
| 224 |
+
src = sources.get(str(idx))
|
| 225 |
+
if src:
|
| 226 |
+
try:
|
| 227 |
+
pils.append(_b64_to_pil(src))
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
return pils
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _spawn_log(target_pil, source_pils, result_pil, selected_indices, duration, success, error=""):
|
| 234 |
+
threading.Thread(
|
| 235 |
+
target=log_inference,
|
| 236 |
+
args=(target_pil, source_pils, result_pil, selected_indices, duration, success, error),
|
| 237 |
+
daemon=True,
|
| 238 |
+
).start()
|
| 239 |
+
|
| 240 |
+
|
| 241 |
# ββ Public Gradio functions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
|
| 243 |
def detect_faces(target_b64):
|
|
|
|
| 303 |
duration = time.perf_counter() - t0
|
| 304 |
print(f"[swap] done in {duration:.2f}s", flush=True)
|
| 305 |
|
| 306 |
+
source_pils = _collect_source_pils(selected_indices, sources)
|
| 307 |
+
_spawn_log(target_pil, source_pils, result_pil, selected_indices, duration, True)
|
| 308 |
yield result_pil, ""
|
| 309 |
|
| 310 |
except gr.Error:
|
| 311 |
raise
|
| 312 |
except Exception as e:
|
| 313 |
print(f"[swap] ERROR: {e}\n{traceback.format_exc()}", flush=True)
|
| 314 |
+
source_pils = _collect_source_pils(selected_indices, sources)
|
| 315 |
+
_spawn_log(target_pil, source_pils, None, selected_indices,
|
| 316 |
+
time.perf_counter() - t0, False, str(e))
|
| 317 |
raise
|
| 318 |
finally:
|
| 319 |
gc.collect()
|
logging_utils.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
+
|
| 7 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 8 |
+
DATASET_REPO = os.environ.get("LOG_DATASET_REPO")
|
| 9 |
+
MAX_LOG_DAYS = int(os.environ.get("LOG_MAX_DAYS", "7"))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _img_to_jpeg(img, quality=85):
|
| 13 |
+
if img is None:
|
| 14 |
+
return None
|
| 15 |
+
buf = BytesIO()
|
| 16 |
+
img.convert("RGB").save(buf, format="JPEG", quality=quality)
|
| 17 |
+
return buf.getvalue()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _build_table(target_pil, source_pils, output_pil, selected_indices,
|
| 21 |
+
duration_seconds, success, error_message, now):
|
| 22 |
+
import json as _json
|
| 23 |
+
import pyarrow as pa
|
| 24 |
+
|
| 25 |
+
img_struct = pa.struct([("bytes", pa.binary()), ("path", pa.string())])
|
| 26 |
+
hf_meta = _json.dumps({"info": {"features": {
|
| 27 |
+
"timestamp": {"dtype": "float64", "_type": "Value"},
|
| 28 |
+
"target_image": {"_type": "Image"},
|
| 29 |
+
"source_images": {"feature": {"_type": "Image"}, "_type": "Sequence"},
|
| 30 |
+
"output_image": {"_type": "Image"},
|
| 31 |
+
"selected_indices": {"feature": {"dtype": "int32", "_type": "Value"}, "_type": "Sequence"},
|
| 32 |
+
"n_faces_swapped": {"dtype": "int32", "_type": "Value"},
|
| 33 |
+
"duration_seconds": {"dtype": "float32", "_type": "Value"},
|
| 34 |
+
"success": {"dtype": "bool", "_type": "Value"},
|
| 35 |
+
"error_message": {"dtype": "string", "_type": "Value"},
|
| 36 |
+
}}}).encode()
|
| 37 |
+
schema = pa.schema([
|
| 38 |
+
("timestamp", pa.float64()),
|
| 39 |
+
("target_image", img_struct),
|
| 40 |
+
("source_images", pa.list_(img_struct)),
|
| 41 |
+
("output_image", img_struct),
|
| 42 |
+
("selected_indices", pa.list_(pa.int32())),
|
| 43 |
+
("n_faces_swapped", pa.int32()),
|
| 44 |
+
("duration_seconds", pa.float32()),
|
| 45 |
+
("success", pa.bool_()),
|
| 46 |
+
("error_message", pa.string()),
|
| 47 |
+
], metadata={b"huggingface": hf_meta})
|
| 48 |
+
|
| 49 |
+
def _img(b):
|
| 50 |
+
return {"bytes": b, "path": None}
|
| 51 |
+
|
| 52 |
+
target_jpeg = _img_to_jpeg(target_pil)
|
| 53 |
+
source_jpegs = [_img_to_jpeg(img) for img in source_pils]
|
| 54 |
+
output_jpeg = _img_to_jpeg(output_pil)
|
| 55 |
+
|
| 56 |
+
return pa.table({
|
| 57 |
+
"timestamp": pa.array([now.timestamp()], type=pa.float64()),
|
| 58 |
+
"target_image": pa.array([_img(target_jpeg) if target_jpeg else None], type=img_struct),
|
| 59 |
+
"source_images": pa.array([[_img(b) for b in source_jpegs]], type=pa.list_(img_struct)),
|
| 60 |
+
"output_image": pa.array([_img(output_jpeg) if output_jpeg else None], type=img_struct),
|
| 61 |
+
"selected_indices": pa.array([[int(i) for i in selected_indices]], type=pa.list_(pa.int32())),
|
| 62 |
+
"n_faces_swapped": pa.array([len(selected_indices)], type=pa.int32()),
|
| 63 |
+
"duration_seconds": pa.array([float(duration_seconds)], type=pa.float32()),
|
| 64 |
+
"success": pa.array([bool(success)], type=pa.bool_()),
|
| 65 |
+
"error_message": pa.array([str(error_message)], type=pa.string()),
|
| 66 |
+
}, schema=schema)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _upload_parquet(api, repo_id, table, path_in_repo):
|
| 70 |
+
import tempfile
|
| 71 |
+
import pyarrow.parquet as pq
|
| 72 |
+
|
| 73 |
+
tmp_path = None
|
| 74 |
+
try:
|
| 75 |
+
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
|
| 76 |
+
tmp_path = tmp.name
|
| 77 |
+
pq.write_table(table, tmp_path)
|
| 78 |
+
print(f"[log] uploading {path_in_repo} ({os.path.getsize(tmp_path)//1024}KB)")
|
| 79 |
+
api.upload_file(
|
| 80 |
+
path_or_fileobj=tmp_path, path_in_repo=path_in_repo,
|
| 81 |
+
repo_id=repo_id, repo_type="dataset",
|
| 82 |
+
)
|
| 83 |
+
print(f"[log] upload done β {repo_id}/{path_in_repo}")
|
| 84 |
+
finally:
|
| 85 |
+
if tmp_path:
|
| 86 |
+
try:
|
| 87 |
+
os.unlink(tmp_path)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f"[log] failed to delete temp file {tmp_path}: {e}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _make_path(now, uid):
|
| 93 |
+
return f"data/{now.strftime('%Y-%m-%d-%H%M%S')}-{uid}.parquet"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _file_date(path):
|
| 97 |
+
return os.path.basename(path)[:10]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _maybe_squash_history(api, repo_id, now):
|
| 101 |
+
marker = "metadata/last_squash.txt"
|
| 102 |
+
today = now.strftime("%Y-%m-%d")
|
| 103 |
+
try:
|
| 104 |
+
try:
|
| 105 |
+
local = hf_hub_download(repo_id=repo_id, filename=marker,
|
| 106 |
+
repo_type="dataset", token=api.token)
|
| 107 |
+
if open(local).read().strip() == today:
|
| 108 |
+
return
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"[log] squash marker not found ({e}), proceeding with squash")
|
| 111 |
+
|
| 112 |
+
api.super_squash_history(repo_id=repo_id, repo_type="dataset")
|
| 113 |
+
print(f"[log] squashed history for {repo_id}")
|
| 114 |
+
|
| 115 |
+
api.upload_file(
|
| 116 |
+
path_or_fileobj=today.encode(), path_in_repo=marker,
|
| 117 |
+
repo_id=repo_id, repo_type="dataset",
|
| 118 |
+
)
|
| 119 |
+
print(f"[log] updated squash marker: {today}")
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"[log] squash warning: {e}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _prune_old_files(api, repo_id, keep_days, now):
|
| 125 |
+
if keep_days <= 0:
|
| 126 |
+
return
|
| 127 |
+
cutoff = (now - timedelta(days=keep_days)).strftime("%Y-%m-%d")
|
| 128 |
+
try:
|
| 129 |
+
to_delete = [
|
| 130 |
+
f.path
|
| 131 |
+
for f in api.list_repo_tree(repo_id, repo_type="dataset", path_in_repo="data")
|
| 132 |
+
if f.path.endswith(".parquet") and _file_date(f.path) < cutoff
|
| 133 |
+
]
|
| 134 |
+
for path in to_delete:
|
| 135 |
+
api.delete_file(path_in_repo=path, repo_id=repo_id, repo_type="dataset")
|
| 136 |
+
print(f"[log] pruned: {path}")
|
| 137 |
+
if to_delete:
|
| 138 |
+
print(f"[log] pruned {len(to_delete)} old file(s)")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f"[log] prune warning: {e}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def log_inference(target_pil, source_pils, output_pil, selected_indices,
|
| 144 |
+
duration_seconds, success, error_message=""):
|
| 145 |
+
import time as _time
|
| 146 |
+
_t0 = _time.perf_counter()
|
| 147 |
+
if not HF_TOKEN or not DATASET_REPO:
|
| 148 |
+
print(f"[log] skipped β HF_TOKEN={'set' if HF_TOKEN else 'missing'}, DATASET_REPO={'set' if DATASET_REPO else 'missing'}")
|
| 149 |
+
return
|
| 150 |
+
try:
|
| 151 |
+
from huggingface_hub import HfApi
|
| 152 |
+
|
| 153 |
+
now = datetime.now(timezone.utc)
|
| 154 |
+
|
| 155 |
+
_t1 = _time.perf_counter()
|
| 156 |
+
table = _build_table(target_pil, source_pils, output_pil, selected_indices,
|
| 157 |
+
duration_seconds, success, error_message, now)
|
| 158 |
+
print(f"[log] build_table: {_time.perf_counter() - _t1:.3f}s")
|
| 159 |
+
|
| 160 |
+
uid = uuid.uuid4().hex[:8]
|
| 161 |
+
path_in_repo = _make_path(now, uid)
|
| 162 |
+
|
| 163 |
+
_t2 = _time.perf_counter()
|
| 164 |
+
api = HfApi(token=HF_TOKEN)
|
| 165 |
+
api.create_repo(repo_id=DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
|
| 166 |
+
print(f"[log] create_repo: {_time.perf_counter() - _t2:.3f}s")
|
| 167 |
+
|
| 168 |
+
_t3 = _time.perf_counter()
|
| 169 |
+
_upload_parquet(api, DATASET_REPO, table, path_in_repo)
|
| 170 |
+
print(f"[log] upload_parquet: {_time.perf_counter() - _t3:.3f}s")
|
| 171 |
+
|
| 172 |
+
_t4 = _time.perf_counter()
|
| 173 |
+
_prune_old_files(api, DATASET_REPO, MAX_LOG_DAYS, now)
|
| 174 |
+
print(f"[log] prune_old_files: {_time.perf_counter() - _t4:.3f}s")
|
| 175 |
+
|
| 176 |
+
_t5 = _time.perf_counter()
|
| 177 |
+
_maybe_squash_history(api, DATASET_REPO, now)
|
| 178 |
+
print(f"[log] squash_history: {_time.perf_counter() - _t5:.3f}s")
|
| 179 |
+
except Exception as log_err:
|
| 180 |
+
import traceback as _tb
|
| 181 |
+
print(f"[log] WARNING: {log_err}\n{_tb.format_exc()}")
|
| 182 |
+
finally:
|
| 183 |
+
print(f"[log] log_inference total: {_time.perf_counter() - _t0:.3f}s")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "selectable-face-swap"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
requires-python = ">=3.10"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"spaces",
|
| 7 |
+
"insightface",
|
| 8 |
+
"onnxruntime",
|
| 9 |
+
"gfpgan",
|
| 10 |
+
"gradio",
|
| 11 |
+
"huggingface_hub",
|
| 12 |
+
"numpy",
|
| 13 |
+
"opencv-python-headless",
|
| 14 |
+
"Pillow",
|
| 15 |
+
"pyarrow",
|
| 16 |
+
"torch",
|
| 17 |
+
"torchvision",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
dev = ["pytest"]
|
| 22 |
+
|
| 23 |
+
[tool.uv]
|
| 24 |
+
package = false
|
requirements.txt
CHANGED
|
@@ -8,5 +8,6 @@ huggingface_hub
|
|
| 8 |
numpy
|
| 9 |
opencv-python-headless
|
| 10 |
Pillow
|
|
|
|
| 11 |
torch
|
| 12 |
torchvision
|
|
|
|
| 8 |
numpy
|
| 9 |
opencv-python-headless
|
| 10 |
Pillow
|
| 11 |
+
pyarrow
|
| 12 |
torch
|
| 13 |
torchvision
|
tests/test_app.py
CHANGED
|
@@ -319,7 +319,7 @@ class TestSwapFacesValidation:
|
|
| 319 |
|
| 320 |
sources = json.dumps({"0": small_rgb_b64})
|
| 321 |
with (
|
| 322 |
-
patch.object(app, "
|
| 323 |
patch.object(app, "_make_swapper", return_value=fake_swapper),
|
| 324 |
patch.object(app, "_make_enhancer", return_value=fake_enhancer),
|
| 325 |
patch.object(app, "_spawn_log"),
|
|
|
|
| 319 |
|
| 320 |
sources = json.dumps({"0": small_rgb_b64})
|
| 321 |
with (
|
| 322 |
+
patch.object(app, "_make_face_analyzer", return_value=fake_analyzer),
|
| 323 |
patch.object(app, "_make_swapper", return_value=fake_swapper),
|
| 324 |
patch.object(app, "_make_enhancer", return_value=fake_enhancer),
|
| 325 |
patch.object(app, "_spawn_log"),
|
tests/test_logging_utils.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from datetime import datetime, timezone, timedelta
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
from unittest.mock import MagicMock
|
| 5 |
+
|
| 6 |
+
sys.path.insert(0, ".")
|
| 7 |
+
from logging_utils import _prune_old_files, _file_date, _make_path, _maybe_squash_history
|
| 8 |
+
|
| 9 |
+
NOW = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc)
|
| 10 |
+
KEEP_DAYS = 7
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _make_api(paths):
|
| 14 |
+
api = MagicMock()
|
| 15 |
+
api.list_repo_tree.return_value = [SimpleNamespace(path=p) for p in paths]
|
| 16 |
+
return api
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _file(days_ago):
|
| 20 |
+
return _make_path(NOW - timedelta(days=days_ago), "abcd1234")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _deleted_paths(api):
|
| 24 |
+
return {c.kwargs["path_in_repo"] for c in api.delete_file.call_args_list}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_file_date_matches_make_path():
|
| 28 |
+
"""_file_date(_make_path(now, uid)) must return now's date."""
|
| 29 |
+
import uuid
|
| 30 |
+
path = _make_path(NOW, uuid.uuid4().hex[:8])
|
| 31 |
+
assert _file_date(path) == NOW.strftime("%Y-%m-%d")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_old_files_are_deleted():
|
| 35 |
+
api = _make_api([_file(8), _file(10)])
|
| 36 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 37 |
+
assert _deleted_paths(api) == {_file(8), _file(10)}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_recent_files_are_kept():
|
| 41 |
+
api = _make_api([_file(6), _file(1), _file(0)])
|
| 42 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 43 |
+
api.delete_file.assert_not_called()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_boundary_exactly_keep_days_old_is_kept():
|
| 47 |
+
api = _make_api([_file(KEEP_DAYS)])
|
| 48 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 49 |
+
api.delete_file.assert_not_called()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_one_day_past_boundary_is_deleted():
|
| 53 |
+
api = _make_api([_file(KEEP_DAYS + 1)])
|
| 54 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 55 |
+
assert _deleted_paths(api) == {_file(KEEP_DAYS + 1)}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_only_old_files_deleted_in_mixed_set():
|
| 59 |
+
api = _make_api([_file(8), _file(6), _file(10), _file(0)])
|
| 60 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 61 |
+
assert _deleted_paths(api) == {_file(8), _file(10)}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_empty_directory_does_nothing():
|
| 65 |
+
api = _make_api([])
|
| 66 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 67 |
+
api.delete_file.assert_not_called()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_keep_days_zero_skips_pruning():
|
| 71 |
+
api = _make_api([_file(8)])
|
| 72 |
+
_prune_old_files(api, "org/repo", keep_days=0, now=NOW)
|
| 73 |
+
api.list_repo_tree.assert_not_called()
|
| 74 |
+
api.delete_file.assert_not_called()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_non_parquet_files_are_ignored():
|
| 78 |
+
api = _make_api(["data/2020-01-01-README.md", _file(8)])
|
| 79 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
|
| 80 |
+
assert _deleted_paths(api) == {_file(8)}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_api_error_does_not_raise():
|
| 84 |
+
api = MagicMock()
|
| 85 |
+
api.list_repo_tree.side_effect = RuntimeError("network error")
|
| 86 |
+
_prune_old_files(api, "org/repo", KEEP_DAYS, NOW) # must not raise
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ββ _maybe_squash_history ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
|
| 91 |
+
def _squash_api():
|
| 92 |
+
api = MagicMock()
|
| 93 |
+
api.token = "tok"
|
| 94 |
+
return api
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_squash_runs_when_no_marker(monkeypatch):
|
| 98 |
+
monkeypatch.setattr(
|
| 99 |
+
"logging_utils.hf_hub_download",
|
| 100 |
+
MagicMock(side_effect=FileNotFoundError("no marker")),
|
| 101 |
+
)
|
| 102 |
+
api = _squash_api()
|
| 103 |
+
_maybe_squash_history(api, "org/repo", NOW)
|
| 104 |
+
api.super_squash_history.assert_called_once()
|
| 105 |
+
api.upload_file.assert_called_once()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_squash_skipped_when_marker_is_today(monkeypatch, tmp_path):
|
| 109 |
+
marker = tmp_path / "last_squash.txt"
|
| 110 |
+
marker.write_text(NOW.strftime("%Y-%m-%d"))
|
| 111 |
+
monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
|
| 112 |
+
api = _squash_api()
|
| 113 |
+
_maybe_squash_history(api, "org/repo", NOW)
|
| 114 |
+
api.super_squash_history.assert_not_called()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_squash_runs_when_marker_is_yesterday(monkeypatch, tmp_path):
|
| 118 |
+
marker = tmp_path / "last_squash.txt"
|
| 119 |
+
marker.write_text((NOW - timedelta(days=1)).strftime("%Y-%m-%d"))
|
| 120 |
+
monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
|
| 121 |
+
api = _squash_api()
|
| 122 |
+
_maybe_squash_history(api, "org/repo", NOW)
|
| 123 |
+
api.super_squash_history.assert_called_once()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_squash_error_does_not_raise(monkeypatch):
|
| 127 |
+
monkeypatch.setattr(
|
| 128 |
+
"logging_utils.hf_hub_download",
|
| 129 |
+
MagicMock(side_effect=FileNotFoundError("no marker")),
|
| 130 |
+
)
|
| 131 |
+
api = _squash_api()
|
| 132 |
+
api.super_squash_history.side_effect = RuntimeError("squash failed")
|
| 133 |
+
_maybe_squash_history(api, "org/repo", NOW) # must not raise
|