| """ |
| User History is a plugin that you can add to your Spaces to cache generated images for your users. |
| |
| Key features: |
| - 🤗 Sign in with Hugging Face |
| - Save generated images with their metadata: prompts, timestamp, hyper-parameters, etc. |
| - Export your history as zip. |
| - Delete your history to respect privacy. |
| - Compatible with Persistent Storage for long-term storage. |
| - Admin panel to check configuration and disk usage . |
| |
| Useful links: |
| - Demo: https://huggingface.co/spaces/Wauplin/gradio-user-history |
| - README: https://huggingface.co/spaces/Wauplin/gradio-user-history/blob/main/README.md |
| - Source file: https://huggingface.co/spaces/Wauplin/gradio-user-history/blob/main/user_history.py |
| - Discussions: https://huggingface.co/spaces/Wauplin/gradio-user-history/discussions |
| """ |
| import json |
| import os |
| import shutil |
| import warnings |
| from datetime import datetime |
| from functools import cache |
| from pathlib import Path |
| from typing import Callable, Dict, List, Tuple |
| from uuid import uuid4 |
|
|
| import gradio as gr |
| import numpy as np |
| import requests |
| from filelock import FileLock |
| from PIL.Image import Image |
|
|
|
|
| def setup(folder_path: str | Path | None = None) -> None: |
| user_history = _UserHistory() |
| user_history.folder_path = _resolve_folder_path(folder_path) |
| user_history.initialized = True |
|
|
| |
| _migrate_history() |
|
|
|
|
| def render() -> None: |
| user_history = _UserHistory() |
|
|
| |
| if not user_history.initialized: |
| print("Initializing user history with default config. Use `user_history.setup(...)` to customize folder_path.") |
| setup() |
|
|
| |
| gr.Markdown( |
| "## Your past generations\n\nLog in to keep a gallery of your previous generations. Your history will be saved" |
| " and available on your next visit. Make sure to export your images from time to time as this gallery may be" |
| " deleted in the future." |
| ) |
|
|
| if os.getenv("SYSTEM") == "spaces" and not os.path.exists("/data"): |
| gr.Markdown( |
| "**⚠️ Persistent storage is disabled, meaning your history will be lost if the Space gets restarted." |
| " Only the Space owner can setup a Persistent Storage. If you are not the Space owner, consider" |
| " duplicating this Space to set your own storage.⚠️**" |
| ) |
|
|
| with gr.Row(): |
| gr.LoginButton(min_width=250) |
| gr.LogoutButton(min_width=250) |
| refresh_button = gr.Button( |
| "Refresh", |
| icon="https://huggingface.co/spaces/Wauplin/gradio-user-history/resolve/main/assets/icon_refresh.png", |
| ) |
| export_button = gr.Button( |
| "Export", |
| icon="https://huggingface.co/spaces/Wauplin/gradio-user-history/resolve/main/assets/icon_download.png", |
| ) |
| delete_button = gr.Button( |
| "Delete history", |
| icon="https://huggingface.co/spaces/Wauplin/gradio-user-history/resolve/main/assets/icon_delete.png", |
| ) |
|
|
| |
| with gr.Row(): |
| export_file = gr.File(file_count="single", file_types=[".zip"], label="Exported history", visible=False) |
|
|
| |
| with gr.Row(): |
| confirm_button = gr.Button("Confirm delete all history", variant="stop", visible=False) |
| cancel_button = gr.Button("Cancel", visible=False) |
|
|
| |
| gallery = gr.Gallery( |
| label="Past images", |
| show_label=True, |
| elem_id="gallery-history", |
| object_fit="contain", |
| columns=5, |
| height=600, |
| preview=False, |
| show_share_button=False, |
| show_download_button=False, |
| ) |
| gr.Markdown( |
| "User history is powered by" |
| " [Wauplin/gradio-user-history](https://huggingface.co/spaces/Wauplin/gradio-user-history). Integrate it to" |
| " your own Space in just a few lines of code!" |
| ) |
| gallery.attach_load_event(_fetch_user_history, every=None) |
|
|
| |
| refresh_button.click(fn=_fetch_user_history, inputs=[], outputs=[gallery], queue=False) |
| export_button.click(fn=_export_user_history, inputs=[], outputs=[export_file], queue=False) |
|
|
| |
| delete_button.click( |
| lambda: [gr.update(visible=True), gr.update(visible=True)], |
| outputs=[confirm_button, cancel_button], |
| queue=False, |
| ) |
| cancel_button.click( |
| lambda: [gr.update(visible=False), gr.update(visible=False)], |
| outputs=[confirm_button, cancel_button], |
| queue=False, |
| ) |
| confirm_button.click(_delete_user_history).then( |
| lambda: [gr.update(visible=False), gr.update(visible=False)], |
| outputs=[confirm_button, cancel_button], |
| queue=False, |
| ) |
|
|
| |
| _admin_section() |
|
|
|
|
| def save_image( |
| profile: gr.OAuthProfile | None, |
| image: Image | np.ndarray | str | Path, |
| label: str | None = None, |
| metadata: Dict | None = None, |
| ): |
| |
| if profile is None: |
| return |
| username = profile["preferred_username"] |
|
|
| |
| user_history = _UserHistory() |
| if not user_history.initialized: |
| warnings.warn( |
| "User history is not set in Gradio demo. Saving image is ignored. You must use `user_history.render(...)`" |
| " first." |
| ) |
| return |
|
|
| |
| image_path = _copy_image(image, dst_folder=user_history._user_images_path(username)) |
|
|
| |
| if metadata is None: |
| metadata = {} |
| if "datetime" not in metadata: |
| metadata["datetime"] = str(datetime.now()) |
| data = {"path": str(image_path), "label": label, "metadata": metadata} |
| with user_history._user_lock(username): |
| with user_history._user_jsonl_path(username).open("a") as f: |
| f.write(json.dumps(data) + "\n") |
|
|
|
|
| |
| |
| |
|
|
|
|
| class _UserHistory(object): |
| _instance = None |
| initialized: bool = False |
| folder_path: Path |
|
|
| def __new__(cls): |
| |
| |
| if cls._instance is None: |
| cls._instance = super(_UserHistory, cls).__new__(cls) |
| return cls._instance |
|
|
| def _user_path(self, username: str) -> Path: |
| path = self.folder_path / username |
| path.mkdir(parents=True, exist_ok=True) |
| return path |
|
|
| def _user_lock(self, username: str) -> FileLock: |
| """Ensure history is not corrupted if concurrent calls.""" |
| return FileLock(self.folder_path / f"{username}.lock") |
|
|
| def _user_jsonl_path(self, username: str) -> Path: |
| return self._user_path(username) / "history.jsonl" |
|
|
| def _user_images_path(self, username: str) -> Path: |
| path = self._user_path(username) / "images" |
| path.mkdir(parents=True, exist_ok=True) |
| return path |
|
|
|
|
| def _fetch_user_history(profile: gr.OAuthProfile | None) -> List[Tuple[str, str]]: |
| """Return saved history for that user, if it exists.""" |
| |
| if profile is None: |
| return [] |
| username = profile["preferred_username"] |
|
|
| user_history = _UserHistory() |
| if not user_history.initialized: |
| warnings.warn("User history is not set in Gradio demo. You must use `user_history.render(...)` first.") |
| return [] |
|
|
| with user_history._user_lock(username): |
| |
| jsonl_path = user_history._user_jsonl_path(username) |
| if not jsonl_path.is_file(): |
| return [] |
|
|
| |
| images = [] |
| for line in jsonl_path.read_text().splitlines(): |
| data = json.loads(line) |
| images.append((data["path"], data["label"] or "")) |
| return list(reversed(images)) |
|
|
|
|
| def _export_user_history(profile: gr.OAuthProfile | None) -> Dict | None: |
| """Zip all history for that user, if it exists and return it as a downloadable file.""" |
| |
| if profile is None: |
| return None |
| username = profile["preferred_username"] |
|
|
| user_history = _UserHistory() |
| if not user_history.initialized: |
| warnings.warn("User history is not set in Gradio demo. You must use `user_history.render(...)` first.") |
| return None |
|
|
| |
| with user_history._user_lock(username): |
| path = shutil.make_archive( |
| str(_archives_path() / f"history_{username}"), "zip", user_history._user_path(username) |
| ) |
|
|
| return gr.update(visible=True, value=path) |
|
|
|
|
| def _delete_user_history(profile: gr.OAuthProfile | None) -> None: |
| """Delete all history for that user.""" |
| |
| if profile is None: |
| return |
| username = profile["preferred_username"] |
|
|
| user_history = _UserHistory() |
| if not user_history.initialized: |
| warnings.warn("User history is not set in Gradio demo. You must use `user_history.render(...)` first.") |
| return |
|
|
| with user_history._user_lock(username): |
| shutil.rmtree(user_history._user_path(username)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _copy_image(image: Image | np.ndarray | str | Path, dst_folder: Path) -> Path: |
| """Copy image to the images folder.""" |
| |
| if isinstance(image, str): |
| image = Path(image) |
| if isinstance(image, Path): |
| dst = dst_folder / f"{uuid4().hex}_{Path(image).name}" |
| shutil.copyfile(image, dst) |
| return dst |
|
|
| |
| if isinstance(image, np.ndarray): |
| image = Image.fromarray(image) |
| if isinstance(image, Image): |
| dst = dst_folder / f"{uuid4().hex}.png" |
| image.save(dst) |
| return dst |
|
|
| raise ValueError(f"Unsupported image type: {type(image)}") |
|
|
|
|
| def _resolve_folder_path(folder_path: str | Path | None) -> Path: |
| if folder_path is not None: |
| return Path(folder_path).expanduser().resolve() |
|
|
| if os.getenv("SYSTEM") == "spaces" and os.path.exists("/data"): |
| return Path("/data") / "_user_history" |
|
|
| |
| return Path(__file__).parent / "_user_history" |
|
|
|
|
| def _archives_path() -> Path: |
| |
| path = Path(__file__).parent / "_user_history_exports" |
| path.mkdir(parents=True, exist_ok=True) |
| return path |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _admin_section() -> None: |
| title = gr.Markdown() |
| title.attach_load_event(_display_if_admin(), every=None) |
|
|
|
|
| def _display_if_admin() -> Callable: |
| def _inner(profile: gr.OAuthProfile | None) -> str: |
| if profile is None: |
| return "" |
| if profile["preferred_username"] in _fetch_admins(): |
| return _admin_content() |
| return "" |
|
|
| return _inner |
|
|
|
|
| def _admin_content() -> str: |
| return f""" |
| ## Admin section |
| |
| Running on **{os.getenv("SYSTEM", "local")}** (id: {os.getenv("SPACE_ID")}). {_get_msg_is_persistent_storage_enabled()} |
| |
| Admins: {', '.join(_fetch_admins())} |
| |
| {_get_nb_users()} user(s), {_get_nb_images()} image(s) |
| |
| ### Configuration |
| |
| History folder: *{_UserHistory().folder_path}* |
| |
| Exports folder: *{_archives_path()}* |
| |
| ### Disk usage |
| |
| {_disk_space_warning_message()} |
| """ |
|
|
|
|
| def _get_nb_users() -> int: |
| user_history = _UserHistory() |
| if not user_history.initialized: |
| return 0 |
| if user_history.folder_path is not None: |
| return len([path for path in user_history.folder_path.iterdir() if path.is_dir()]) |
| return 0 |
|
|
|
|
| def _get_nb_images() -> int: |
| user_history = _UserHistory() |
| if not user_history.initialized: |
| return 0 |
| if user_history.folder_path is not None: |
| return len([path for path in user_history.folder_path.glob("*/images/*")]) |
| return 0 |
|
|
|
|
| def _get_msg_is_persistent_storage_enabled() -> str: |
| if os.getenv("SYSTEM") == "spaces": |
| if os.path.exists("/data"): |
| return "Persistent storage is enabled." |
| else: |
| return ( |
| "Persistent storage is not enabled. This means that user histories will be deleted when the Space is" |
| " restarted. Consider adding a Persistent Storage in your Space settings." |
| ) |
| return "" |
|
|
|
|
| def _disk_space_warning_message() -> str: |
| user_history = _UserHistory() |
| if not user_history.initialized: |
| return "" |
|
|
| message = "" |
| if user_history.folder_path is not None: |
| total, used, _ = _get_disk_usage(user_history.folder_path) |
| message += f"History folder: **{used / 1e9 :.0f}/{total / 1e9 :.0f}GB** used ({100*used/total :.0f}%)." |
|
|
| total, used, _ = _get_disk_usage(_archives_path()) |
| message += f"\n\nExports folder: **{used / 1e9 :.0f}/{total / 1e9 :.0f}GB** used ({100*used/total :.0f}%)." |
|
|
| return f"{message.strip()}" |
|
|
|
|
| def _get_disk_usage(path: Path) -> Tuple[int, int, int]: |
| for path in [path] + list(path.parents): |
| try: |
| return shutil.disk_usage(path) |
| except OSError: |
| pass |
| return 0, 0, 0 |
|
|
|
|
| @cache |
| def _fetch_admins() -> List[str]: |
| |
| if os.getenv("SYSTEM") != "spaces": |
| return ["FakeGradioUser"] |
|
|
| |
| space_id = os.getenv("SPACE_ID") |
| if space_id is None: |
| return ["Unknown"] |
|
|
| |
| |
| namespace = space_id.split("/")[0] |
| response = requests.get(f"https://huggingface.co/api/organizations/{namespace}/members") |
| if response.status_code == 200: |
| return sorted((member["user"] for member in response.json()), key=lambda x: x.lower()) |
| return [namespace] |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| def _migrate_history(): |
| """Script to migrate user history from v0 to v1.""" |
| legacy_history_path = _legacy_get_history_folder_path() |
| if not legacy_history_path.exists(): |
| return |
|
|
| error_count = 0 |
| for json_path in legacy_history_path.glob("*.json"): |
| username = json_path.stem |
| print(f"Migrating history for user {username}...") |
| error_count += _legacy_move_user_history(username) |
| print("Done.") |
| print(f"Migration complete. {error_count} error(s) happened.") |
|
|
| if error_count == 0: |
| shutil.rmtree(legacy_history_path, ignore_errors=True) |
|
|
|
|
| def _legacy_move_user_history(username: str) -> int: |
| history = _legacy_read_user_history(username) |
| error_count = 0 |
| for image, prompt in reversed(history): |
| try: |
| save_image(label=prompt, image=image, profile={"preferred_username": username}) |
| except Exception as e: |
| print("Issue while migrating image:", e) |
| error_count += 1 |
| return error_count |
|
|
|
|
| def _legacy_get_history_folder_path() -> Path: |
| _folder = os.environ.get("HISTORY_FOLDER") |
| if _folder is None: |
| _folder = Path(__file__).parent / "history" |
| return Path(_folder) |
|
|
|
|
| def _legacy_read_user_history(username: str) -> List[Tuple[str, str]]: |
| """Return saved history for that user.""" |
| with _legacy_user_lock(username): |
| path = _legacy_user_history_path(username) |
| if path.exists(): |
| return json.loads(path.read_text()) |
| return [] |
|
|
|
|
| def _legacy_user_history_path(username: str) -> Path: |
| return _legacy_get_history_folder_path() / f"{username}.json" |
|
|
|
|
| def _legacy_user_lock(username: str) -> FileLock: |
| """Ensure history is not corrupted if concurrent calls.""" |
| return FileLock(f"{_legacy_user_history_path(username)}.lock") |
|
|