storage.py (14349B)
1 import os 2 import re 3 import time 4 import unicodedata 5 import uuid 6 import json 7 import tempfile 8 import asyncio 9 import httpx 10 from huggingface_hub import HfApi 11 12 # Files live in a Hugging Face Storage Bucket (mutable, non-versioned object 13 # storage) instead of a git-backed dataset repo: every write is a plain object 14 # write rather than a commit, so uploads and folder syncs avoid git overhead and 15 # commit rate limits. HF_REPO_ID is still honoured as a fallback so existing 16 # deployments keep working until HF_BUCKET_ID is set. 17 HF_BUCKET_ID = os.getenv("HF_BUCKET_ID") or os.getenv("HF_REPO_ID", "notamitgamer/cdn") 18 HF_REPO_ID = HF_BUCKET_ID # backwards-compatible alias used by older imports 19 HF_TOKEN = os.getenv("HF_TOKEN") 20 BUCKET_PRIVATE = os.getenv("HF_BUCKET_PRIVATE", "0") == "1" 21 CACHE_TTL = 60 22 MAX_CACHE_SIZE = 500 23 ZIP_MAX_FILES = 300 24 ZIP_MAX_TOTAL_BYTES = 500 * 1024 * 1024 25 REPO_STATS_CACHE_TTL = 300 26 SEARCH_RESULT_LIMIT = 10 27 28 api = HfApi(token=HF_TOKEN) 29 30 31 def bucket_url(path: str) -> str: 32 """Direct (CDN-backed) download URL for an object in the bucket.""" 33 return f"https://huggingface.co/buckets/{HF_BUCKET_ID}/resolve/{path}" 34 35 36 def auth_headers() -> dict: 37 """Bearer header, only needed when the bucket is private.""" 38 if BUCKET_PRIVATE and HF_TOKEN: 39 return {"Authorization": f"Bearer {HF_TOKEN}"} 40 return {} 41 42 43 _bucket_ready = False 44 45 46 def _ensure_bucket(): 47 global _bucket_ready 48 if _bucket_ready: 49 return 50 api.create_bucket(HF_BUCKET_ID, private=BUCKET_PRIVATE, exist_ok=True) 51 _bucket_ready = True 52 53 _cache = {} 54 55 _key_locks: dict[str, asyncio.Lock] = {} 56 57 def _get_key_lock(key: str) -> asyncio.Lock: 58 lock = _key_locks.get(key) 59 if lock is None: 60 lock = asyncio.Lock() 61 _key_locks[key] = lock 62 63 if len(_key_locks) > MAX_CACHE_SIZE: 64 _key_locks.pop(next(iter(_key_locks))) 65 return lock 66 67 def _get_cache(key): 68 if key in _cache: 69 expiry, value = _cache[key] 70 if expiry > time.time(): 71 _cache[key] = _cache.pop(key) 72 return value 73 del _cache[key] 74 return None 75 76 def _set_cache(key, value): 77 if key in _cache: 78 del _cache[key] 79 elif len(_cache) >= MAX_CACHE_SIZE: 80 oldest_key = next(iter(_cache)) 81 del _cache[oldest_key] 82 83 _cache[key] = (time.time() + CACHE_TTL, value) 84 85 async def is_file(path: str) -> bool: 86 info = await get_file_info(path) 87 return info["exists"] 88 89 async def get_file_info(path: str) -> dict: 90 if not path: 91 return {"exists": False, "size": None, "content_type": None} 92 93 cache_key = f"file_info_{path}" 94 cached = _get_cache(cache_key) 95 if cached is not None: 96 return cached 97 98 async with _get_key_lock(cache_key): 99 cached = _get_cache(cache_key) 100 if cached is not None: 101 return cached 102 103 hf_url = bucket_url(path) 104 async with httpx.AsyncClient(follow_redirects=True) as client: 105 r = await client.head(hf_url, headers=auth_headers()) 106 exists = r.status_code == 200 107 size = None 108 content_type = None 109 if exists: 110 try: 111 size = int(r.headers["Content-Length"]) if "Content-Length" in r.headers else None 112 except (ValueError, TypeError): 113 size = None 114 content_type = r.headers.get("Content-Type") 115 result = {"exists": exists, "size": size, "content_type": content_type} 116 _set_cache(cache_key, result) 117 return result 118 119 async def get_path_info(path: str) -> dict: 120 """Like get_file_info(), for deciding whether a URL is a file page or a folder listing. 121 122 Opening a folder used to cost a wasted HEAD request (a folder isn't a file, so it 404s) before the 123 listing was fetched. When the parent folder's listing is already cached, which it normally is 124 because that is the page you clicked from, it already says whether `path` is a file or a folder 125 (and a file's size), so the HEAD is skipped. Anything not found there falls back to get_file_info(). 126 """ 127 parent = path.rpartition("/")[0] 128 for entry in _get_cache(f"list_dir_{parent}") or (): 129 if entry["path"] == path: 130 if entry["is_dir"]: 131 return {"exists": False, "size": None, "content_type": None} 132 return {"exists": True, "size": entry.get("size"), "content_type": None} 133 return await get_file_info(path) 134 135 def _under(path: str, items: list) -> list: 136 """Keep only entries inside `path` (defensive: guards against prefix over-match 137 such as 'docs' also matching 'docs-old/...').""" 138 if not path: 139 return items 140 prefix = path.rstrip("/") + "/" 141 return [it for it in items if it.path.startswith(prefix)] 142 143 def _fetch_tree(path: str): 144 path = path.strip("/") 145 items = list(api.list_bucket_tree( 146 HF_BUCKET_ID, prefix=path or None, recursive=False 147 )) 148 return _under(path, items) 149 150 def _fetch_tree_recursive(path: str = ""): 151 path = path.strip("/") 152 items = list(api.list_bucket_tree( 153 HF_BUCKET_ID, prefix=path or None, recursive=True 154 )) 155 return _under(path, items) 156 157 _HIDDEN_PREFIXES = ("_batches", "_shortened", "_logs", "_admin",) 158 159 def _is_hidden_path(path: str) -> bool: 160 return any(path == p or path.startswith(p + "/") for p in _HIDDEN_PREFIXES) 161 162 async def list_directory(path: str): 163 cache_key = f"list_dir_{path}" 164 cached = _get_cache(cache_key) 165 if cached is not None: 166 return cached 167 168 # One fetch per folder at a time: when a prefetch and a real click ask for the same folder 169 # together, the second waits for the first instead of repeating the Hugging Face call. 170 async with _get_key_lock(cache_key): 171 cached = _get_cache(cache_key) 172 if cached is not None: 173 return cached 174 175 try: 176 items = await asyncio.to_thread(_fetch_tree, path) 177 except Exception as e: 178 print(f"[storage] list_repo_tree failed for {path!r}: {e}") 179 return [] 180 181 files_and_folders = [] 182 for item in items: 183 if _is_hidden_path(item.path): 184 continue 185 186 name = item.path.split("/")[-1] 187 is_dir = not hasattr(item, "size") 188 size = getattr(item, "size", 0) 189 190 size_str = format_size(size) if not is_dir else "-" 191 192 files_and_folders.append({ 193 "name": name, 194 "path": item.path, 195 "is_dir": is_dir, 196 "size": size, 197 "size_str": size_str 198 }) 199 200 files_and_folders.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) 201 202 _set_cache(cache_key, files_and_folders) 203 return files_and_folders 204 205 async def list_files_recursive(path: str): 206 try: 207 items = await asyncio.to_thread(_fetch_tree_recursive, path) 208 except Exception: 209 return [] 210 211 files = [] 212 total_size = 0 213 for item in items: 214 if _is_hidden_path(item.path): 215 continue 216 if hasattr(item, "size"): 217 size = getattr(item, "size", 0) or 0 218 total_size += size 219 files.append({"path": item.path, "size": size}) 220 if len(files) > ZIP_MAX_FILES or total_size > ZIP_MAX_TOTAL_BYTES: 221 raise ValueError( 222 f"Folder too large to zip (limit: {ZIP_MAX_FILES} files / " 223 f"{ZIP_MAX_TOTAL_BYTES // (1024 * 1024)} MB)." 224 ) 225 return files 226 227 def format_size(size_bytes) -> str: 228 if size_bytes is None: 229 return "-" 230 size = float(size_bytes) 231 units = ["B", "KB", "MB", "GB", "TB"] 232 idx = 0 233 while size >= 1024 and idx < len(units) - 1: 234 size /= 1024 235 idx += 1 236 if idx == 0: 237 return f"{int(size)} {units[idx]}" 238 return f"{size:.2f} {units[idx]}" 239 240 async def _get_full_tree(): 241 242 cache_key = "full_tree" 243 cached = _get_cache(cache_key) 244 if cached is not None: 245 return cached 246 247 async with _get_key_lock(cache_key): 248 cached = _get_cache(cache_key) 249 if cached is not None: 250 return cached 251 252 try: 253 raw_items = await asyncio.to_thread(_fetch_tree_recursive, "") 254 except Exception as e: 255 print(f"[storage] full tree fetch failed: {e}") 256 return [] 257 258 items = [] 259 for item in raw_items: 260 if _is_hidden_path(item.path): 261 continue 262 263 name = item.path.split("/")[-1] 264 is_dir = not hasattr(item, "size") 265 size = getattr(item, "size", 0) or 0 266 up = getattr(item, "uploaded_at", None) 267 items.append({ 268 "name": name, 269 "path": item.path, 270 "is_dir": is_dir, 271 "size": size, 272 "ts": int(up.timestamp()) if up else 0, 273 "size_str": format_size(size) if not is_dir else "-", 274 }) 275 276 _set_cache(cache_key, items) 277 return items 278 279 async def repo_stats(): 280 cache_key = "repo_stats" 281 cached = _get_cache(cache_key) 282 if cached is not None: 283 return cached 284 285 items = await _get_full_tree() 286 if not items: 287 return None 288 289 file_count = sum(1 for it in items if not it["is_dir"]) 290 total_size = sum(it["size"] for it in items if not it["is_dir"]) 291 292 result = {"file_count": file_count, "size_str": format_size(total_size)} 293 _set_cache(cache_key, result) 294 return result 295 296 async def search_files(query: str): 297 q = query.strip().lower() 298 if not q: 299 return [] 300 301 items = await _get_full_tree() 302 results = [ 303 it for it in items 304 if q in it["name"].lower() and it["name"] != ".gitattributes" 305 ] 306 results.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) 307 return [ 308 {"name": it["name"], "path": it["path"], "is_dir": it["is_dir"], "size_str": it["size_str"]} 309 for it in results[:SEARCH_RESULT_LIMIT] 310 ] 311 312 def _do_upload(temp_path: str, hf_path: str): 313 _ensure_bucket() 314 api.batch_bucket_files( 315 HF_BUCKET_ID, 316 add=[(temp_path, hf_path)], 317 ) 318 319 def _do_upload_bytes(data: bytes, hf_path: str): 320 _ensure_bucket() 321 api.batch_bucket_files( 322 HF_BUCKET_ID, 323 add=[(data, hf_path)], 324 ) 325 326 def delete_object(path: str): 327 """Delete one object, or everything under path/ (for a synced owner/repo folder).""" 328 _ensure_bucket() 329 path = path.strip("/") 330 items = [it.path for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=path, recursive=True) 331 if it.type == "file" and (it.path == path or it.path.startswith(path + "/"))] 332 if items: 333 api.batch_bucket_files(HF_BUCKET_ID, delete=items) 334 _cache.clear() 335 336 337 _UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+") 338 339 340 def sanitize_filename(filename: str, max_len: int = 100) -> str: 341 """Turn any user-supplied filename into a safe object-key segment. 342 343 Accepts spaces, accents, symbols, emoji and path fragments: directory parts 344 are dropped, accents are folded to ASCII, and anything else unsafe becomes 345 a single underscore. The extension is kept. 346 """ 347 name = (filename or "").replace("\\", "/").split("/")[-1] 348 name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii") 349 stem, dot, ext = name.rpartition(".") 350 if not dot: 351 stem, ext = name, "" 352 stem = _UNSAFE_NAME_CHARS.sub("_", stem).strip("._") or "file" 353 ext = _UNSAFE_NAME_CHARS.sub("", ext)[:10] 354 stem = stem[: max_len - len(ext) - 1] 355 return f"{stem}.{ext}" if ext else stem 356 357 358 async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads") -> str: 359 slug = str(uuid.uuid4())[:8] 360 safe_filename = sanitize_filename(filename) 361 hf_path = f"{folder}/{slug}-{safe_filename}" 362 363 await asyncio.to_thread(_do_upload, temp_path, hf_path) 364 365 _cache.clear() 366 return hf_path 367 368 def _do_upload_folder_scoped(local_dir: str, dest_prefix: str): 369 # Mirror local_dir into the bucket under dest_prefix. Every added path and every 370 # deleted path is built from dest_prefix, so this can only ever create, update 371 # or delete objects under that one folder - it never touches anything outside it. 372 _ensure_bucket() 373 dest_prefix = dest_prefix.strip("/") 374 375 local_files = {} 376 for root, _dirs, names in os.walk(local_dir): 377 for name in names: 378 full = os.path.join(root, name) 379 rel = os.path.relpath(full, local_dir).replace(os.sep, "/") 380 local_files[f"{dest_prefix}/{rel}"] = full 381 382 remote_files = { 383 it.path 384 for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=dest_prefix, recursive=True) 385 if it.type == "file" and it.path.startswith(dest_prefix + "/") 386 } 387 388 stale = sorted(remote_files - local_files.keys()) 389 adds = [(full, remote) for remote, full in sorted(local_files.items())] 390 391 if not adds and not stale: 392 return 393 api.batch_bucket_files( 394 HF_BUCKET_ID, 395 add=adds or None, 396 delete=stale or None, 397 ) 398 399 async def upload_folder_scoped(local_dir: str, owner: str, repo: str) -> str: 400 dest_prefix = f"{owner}/{repo}" 401 await asyncio.to_thread(_do_upload_folder_scoped, local_dir, dest_prefix) 402 _cache.clear() 403 return dest_prefix 404 405 async def write_batch_manifest(batch_id: str, files: list[dict]) -> None: 406 manifest = { 407 "batch_id": batch_id, 408 "created": time.time(), 409 "files": files, 410 } 411 412 fd, tmp_path = tempfile.mkstemp(suffix=".json") 413 try: 414 with os.fdopen(fd, "w") as f: 415 json.dump(manifest, f) 416 await asyncio.to_thread(_do_upload, tmp_path, f"_batches/{batch_id}.json") 417 finally: 418 if os.path.exists(tmp_path): 419 os.remove(tmp_path) 420 421 _cache.clear() 422 423 async def get_batch_manifest(batch_id: str): 424 cache_key = f"batch_{batch_id}" 425 cached = _get_cache(cache_key) 426 if cached is not None: 427 return cached 428 429 hf_url = bucket_url(f"_batches/{batch_id}.json") 430 async with httpx.AsyncClient(follow_redirects=True) as client: 431 r = await client.get(hf_url, headers=auth_headers()) 432 if r.status_code != 200: 433 return None 434 try: 435 manifest = r.json() 436 except ValueError: 437 return None 438 439 _set_cache(cache_key, manifest) 440 return manifest