Spaces:
Sleeping
Sleeping
Download app.py from fffiloni/clone-git-repo-to-space: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/spaces/fffiloni/clone-git-repo-to-space/resolve/main/app.py
- Command line
-
hf download hf://spaces/fffiloni/clone-git-repo-to-space/app.py
-
curl -L -o app.py https://huggingface.co/spaces/fffiloni/clone-git-repo-to-space/resolve/main/app.py
14.8 kB
| import os | |
| import shutil | |
| import tempfile | |
| import time | |
| import fnmatch | |
| from pathlib import Path | |
| import gradio as gr | |
| import httpx | |
| from git import Repo | |
| from huggingface_hub import CommitOperationAdd, HfApi | |
| os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "300" | |
| os.environ["HF_HUB_ETAG_TIMEOUT"] = "300" | |
| os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1" | |
| api = HfApi() | |
| ALLOWED_SPACE_SDKS = {"gradio", "streamlit", "docker", "static"} | |
| def remove_broken_symlinks(directory): | |
| removed = [] | |
| for root, dirs, files in os.walk(directory, topdown=True): | |
| dirs[:] = [d for d in dirs if d != ".git"] | |
| for dirname in dirs[:]: | |
| dirpath = os.path.join(root, dirname) | |
| if os.path.islink(dirpath) and not os.path.exists(dirpath): | |
| os.unlink(dirpath) | |
| dirs.remove(dirname) | |
| removed.append(dirpath) | |
| for filename in files: | |
| filepath = os.path.join(root, filename) | |
| if os.path.islink(filepath) and not os.path.exists(filepath): | |
| os.unlink(filepath) | |
| removed.append(filepath) | |
| return removed | |
| def resolve_symlinks_to_copies(directory): | |
| converted = [] | |
| for root, dirs, files in os.walk(directory, topdown=True): | |
| dirs[:] = [d for d in dirs if d != ".git"] | |
| for filename in files: | |
| filepath = os.path.join(root, filename) | |
| if not os.path.islink(filepath) or not os.path.exists(filepath): | |
| continue | |
| real_path = os.path.realpath(filepath) | |
| try: | |
| os.unlink(filepath) | |
| if os.path.isfile(real_path): | |
| shutil.copy2(real_path, filepath) | |
| elif os.path.isdir(real_path): | |
| shutil.copytree(real_path, filepath) | |
| converted.append(filepath) | |
| except Exception as e: | |
| print(f"Warning: could not convert symlink {filepath}: {e}") | |
| return converted | |
| def count_files_and_size(directory): | |
| total_files = 0 | |
| total_size = 0 | |
| for root, dirs, files in os.walk(directory): | |
| dirs[:] = [d for d in dirs if d != ".git"] | |
| for filename in files: | |
| filepath = os.path.join(root, filename) | |
| if not os.path.exists(filepath): | |
| continue | |
| total_files += 1 | |
| try: | |
| total_size += os.path.getsize(filepath) | |
| except OSError: | |
| pass | |
| return total_files, total_size | |
| def summarize_tree(directory, max_depth=3, max_files_per_dir=10): | |
| lines = [] | |
| base = Path(directory) | |
| for root, dirs, files in os.walk(directory): | |
| dirs[:] = [d for d in dirs if d != ".git"] | |
| rel = Path(root).relative_to(base) | |
| depth = 0 if str(rel) == "." else len(rel.parts) | |
| if depth > max_depth: | |
| dirs[:] = [] | |
| continue | |
| indent = " " * depth | |
| name = base.name if str(rel) == "." else rel.name | |
| lines.append(f"{indent}{name}/") | |
| file_indent = " " * (depth + 1) | |
| for filename in sorted(files)[:max_files_per_dir]: | |
| lines.append(f"{file_indent}{filename}") | |
| if len(files) > max_files_per_dir: | |
| lines.append(f"{file_indent}... and {len(files) - max_files_per_dir} more files") | |
| return "\n".join(lines[:250]) | |
| def normalize_space_id(space_destination, token): | |
| username = api.whoami(token=token)["name"] | |
| if "/" not in space_destination: | |
| return f"{username}/{space_destination}" | |
| return space_destination | |
| def normalize_space_sdk(space_sdk): | |
| if space_sdk is None: | |
| return "gradio" | |
| normalized = str(space_sdk).strip().lower() | |
| if not normalized: | |
| return "gradio" | |
| if normalized not in ALLOWED_SPACE_SDKS: | |
| raise ValueError( | |
| f"Invalid Space SDK `{space_sdk}`. Expected one of: {', '.join(sorted(ALLOWED_SPACE_SDKS))}." | |
| ) | |
| return normalized | |
| def clone_repository(git_repo_url, tmp_dir, recursive_bool): | |
| print(f"Cloning {git_repo_url}") | |
| repo = Repo.clone_from( | |
| git_repo_url, | |
| tmp_dir, | |
| depth=1, | |
| recursive=recursive_bool, | |
| ) | |
| if recursive_bool: | |
| print("Initializing submodules recursively...") | |
| try: | |
| repo.git.submodule("update", "--init", "--recursive", "--depth", "1") | |
| except Exception as shallow_error: | |
| print(f"Shallow submodule update failed, retrying full submodule update: {shallow_error}") | |
| repo.git.submodule("update", "--init", "--recursive") | |
| return repo | |
| def ensure_space_exists(space_destination, token, space_sdk="gradio"): | |
| resolved_space_sdk = normalize_space_sdk(space_sdk) | |
| authenticated_api = HfApi(token=token) | |
| try: | |
| authenticated_api.repo_info(repo_id=space_destination, repo_type="space") | |
| print(f"Space exists: {space_destination}") | |
| return False | |
| except Exception as e: | |
| print(f"Space not found or not accessible, creating it: {e}") | |
| authenticated_api.create_repo( | |
| repo_id=space_destination, | |
| repo_type="space", | |
| private=True, | |
| space_sdk=resolved_space_sdk, | |
| exist_ok=True, | |
| ) | |
| print(f"Space created: {space_destination} with SDK: {resolved_space_sdk}") | |
| return True | |
| def upload_small_folder(tmp_dir, space_destination, token, ignore_patterns): | |
| max_retries = 3 | |
| for attempt in range(max_retries): | |
| try: | |
| print(f"Upload attempt {attempt + 1}/{max_retries}") | |
| api.upload_folder( | |
| folder_path=tmp_dir, | |
| repo_id=space_destination, | |
| repo_type="space", | |
| token=token, | |
| commit_message="Migrated from Git repository", | |
| ignore_patterns=ignore_patterns, | |
| ) | |
| return | |
| except (httpx.ReadTimeout, httpx.ConnectTimeout) as e: | |
| if attempt == max_retries - 1: | |
| raise RuntimeError(f"Upload timeout after {max_retries} attempts: {e}") from e | |
| print(f"Timeout, retrying in 5s: {e}") | |
| time.sleep(5) | |
| def should_ignore_path(relative_path, ignore_patterns): | |
| rel = relative_path.replace(os.sep, "/") | |
| name = os.path.basename(rel) | |
| parts = set(Path(rel).parts) | |
| ignored_dirs = {".git", ".cache", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"} | |
| if parts.intersection(ignored_dirs): | |
| return True | |
| for pattern in ignore_patterns: | |
| normalized = pattern.replace(os.sep, "/") | |
| if fnmatch.fnmatch(rel, normalized) or fnmatch.fnmatch(name, normalized): | |
| return True | |
| if normalized.endswith("/**"): | |
| prefix = normalized[:-3].rstrip("/") | |
| if rel == prefix or rel.startswith(prefix + "/"): | |
| return True | |
| return False | |
| def iter_upload_files(folder_path, ignore_patterns): | |
| folder = Path(folder_path) | |
| for root, dirs, files in os.walk(folder_path): | |
| dirs[:] = [ | |
| dirname | |
| for dirname in dirs | |
| if not should_ignore_path(str((Path(root) / dirname).relative_to(folder)), ignore_patterns) | |
| ] | |
| for filename in files: | |
| local_path = Path(root) / filename | |
| relative_path = str(local_path.relative_to(folder)).replace(os.sep, "/") | |
| if should_ignore_path(relative_path, ignore_patterns): | |
| continue | |
| yield local_path, relative_path | |
| def upload_folder_in_batches(tmp_dir, space_destination, token, ignore_patterns, max_files=75, max_bytes=50 * 1024 * 1024): | |
| authenticated_api = HfApi(token=token) | |
| batch = [] | |
| batch_size = 0 | |
| batch_index = 1 | |
| total_files = 0 | |
| def commit_batch(files, index): | |
| if not files: | |
| return | |
| operations = [ | |
| CommitOperationAdd(path_or_fileobj=str(local_path), path_in_repo=path_in_repo) | |
| for local_path, path_in_repo in files | |
| ] | |
| print(f"Uploading batch {index}: {len(operations)} files") | |
| authenticated_api.create_commit( | |
| repo_id=space_destination, | |
| repo_type="space", | |
| operations=operations, | |
| commit_message=f"Migrated files batch {index}", | |
| ) | |
| for local_path, relative_path in iter_upload_files(tmp_dir, ignore_patterns): | |
| file_size = local_path.stat().st_size | |
| should_flush = batch and (len(batch) >= max_files or batch_size + file_size > max_bytes) | |
| if should_flush: | |
| commit_batch(batch, batch_index) | |
| batch_index += 1 | |
| total_files += len(batch) | |
| batch = [] | |
| batch_size = 0 | |
| batch.append((local_path, relative_path)) | |
| batch_size += file_size | |
| if batch: | |
| commit_batch(batch, batch_index) | |
| total_files += len(batch) | |
| print(f"Uploaded {total_files} files in {batch_index} batch(es)") | |
| def process(git_repo_url, recursive_bool, space_destination, user_token, space_sdk, progress=gr.Progress()): | |
| if not git_repo_url or not space_destination or not user_token: | |
| return "Error: please fill in the Git URL, Space ID, and HF token." | |
| tmp_dir = tempfile.mkdtemp(prefix="clone-to-space-") | |
| try: | |
| progress(0.02, desc="Verifying Hugging Face token...") | |
| resolved_space_sdk = normalize_space_sdk(space_sdk) | |
| space_destination = normalize_space_id(space_destination.strip(), user_token.strip()) | |
| print(f"Target Space: {space_destination}") | |
| print(f"Space SDK: {resolved_space_sdk}") | |
| progress(0.08, desc="Cloning Git repository...") | |
| clone_repository(git_repo_url.strip(), tmp_dir, bool(recursive_bool)) | |
| progress(0.18, desc="Cleaning Git metadata...") | |
| git_dir = os.path.join(tmp_dir, ".git") | |
| if os.path.exists(git_dir): | |
| shutil.rmtree(git_dir) | |
| progress(0.22, desc="Processing symbolic links...") | |
| broken_links = remove_broken_symlinks(tmp_dir) | |
| converted_links = resolve_symlinks_to_copies(tmp_dir) | |
| print(f"Removed {len(broken_links)} broken symlinks") | |
| print(f"Converted {len(converted_links)} valid symlinks") | |
| progress(0.28, desc="Processing README...") | |
| readme_path = os.path.join(tmp_dir, "README.md") | |
| original_readme_path = os.path.join(tmp_dir, "ORIGINAL_README.md") | |
| if os.path.exists(readme_path) and not os.path.exists(original_readme_path): | |
| os.rename(readme_path, original_readme_path) | |
| file_count, total_size = count_files_and_size(tmp_dir) | |
| size_mb = total_size / (1024 * 1024) | |
| print(f"Files: {file_count}, size: {size_mb:.2f} MB") | |
| print("Repository preview:") | |
| print(summarize_tree(tmp_dir)) | |
| progress(0.36, desc="Ensuring destination Space exists...") | |
| ensure_space_exists(space_destination, user_token.strip(), resolved_space_sdk) | |
| ignore_patterns = [ | |
| ".git", | |
| ".git/**", | |
| ".gitignore", | |
| ".gitattributes", | |
| ".DS_Store", | |
| "*.env", | |
| "**/__pycache__/**", | |
| "**/*.pyc", | |
| "**/.pytest_cache/**", | |
| ".cache", | |
| ".cache/**", | |
| "node_modules", | |
| "node_modules/**", | |
| ".venv", | |
| ".venv/**", | |
| "venv", | |
| "venv/**", | |
| ] | |
| if size_mb > 50 or file_count > 500: | |
| progress(0.48, desc=f"Large upload: {file_count} files, {size_mb:.1f} MB") | |
| print("Using chunked create_commit upload for Space compatibility") | |
| upload_folder_in_batches(tmp_dir, space_destination, user_token.strip(), ignore_patterns) | |
| else: | |
| progress(0.48, desc=f"Uploading: {file_count} files, {size_mb:.1f} MB") | |
| print("Using HfApi.upload_folder()") | |
| upload_small_folder(tmp_dir, space_destination, user_token.strip(), ignore_patterns) | |
| progress(1.0, desc="Complete") | |
| space_url = f"https://huggingface.co/spaces/{space_destination}" | |
| return f"Success! View Space: {space_url}" | |
| except Exception as e: | |
| error_msg = str(e) | |
| print(f"Error: {error_msg}") | |
| return f"Error occurred: {error_msg}" | |
| finally: | |
| if os.path.exists(tmp_dir): | |
| shutil.rmtree(tmp_dir) | |
| print("Cleaned up temporary directory") | |
| css = """ | |
| .gradio-container { | |
| max-width: 920px !important; | |
| margin: 0 auto !important; | |
| } | |
| #main-container { | |
| padding: 28px 18px; | |
| } | |
| #main-container h1, | |
| #main-container > .markdown p { | |
| text-align: center; | |
| } | |
| .form-panel { | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: 8px; | |
| padding: 20px; | |
| } | |
| .primary-btn button, | |
| button.primary { | |
| border-radius: 8px !important; | |
| } | |
| """ | |
| with gr.Blocks(theme=gr.themes.Soft(), css=css, title="Clone Git Repo to HF Space") as demo: | |
| with gr.Column(elem_id="main-container"): | |
| gr.Markdown( | |
| """ | |
| # Clone Git Repo to HF Space | |
| Clone a Git repository into a Hugging Face Space, including submodules when Recursive mode is enabled. | |
| """ | |
| ) | |
| with gr.Column(elem_classes=["form-panel"]): | |
| with gr.Row(): | |
| git_repo_url = gr.Textbox( | |
| label="Git Repository URL", | |
| placeholder="https://github.com/username/repo.git", | |
| scale=3, | |
| ) | |
| recursive_mode = gr.Checkbox( | |
| label="Recursive mode", | |
| value=True, | |
| info="Clone Git submodules too. Recommended for repos with third_party dependencies.", | |
| scale=1, | |
| ) | |
| with gr.Row(): | |
| space_destination = gr.Textbox( | |
| label="Destination Space ID", | |
| placeholder="your_hf_username/space_name", | |
| info="If you only enter a Space name, your username is added automatically.", | |
| ) | |
| user_token = gr.Textbox( | |
| label="HF Token with WRITE permission", | |
| type="password", | |
| ) | |
| space_sdk = gr.Radio( | |
| label="Space SDK", | |
| choices=["gradio", "streamlit", "docker", "static"], | |
| value="gradio", | |
| ) | |
| submit_btn = gr.Button("Clone to Space", variant="primary", elem_classes=["primary-btn"]) | |
| status = gr.Textbox(label="Status", lines=4, interactive=False) | |
| submit_btn.click( | |
| fn=process, | |
| inputs=[git_repo_url, recursive_mode, space_destination, user_token, space_sdk], | |
| outputs=[status], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=1, max_size=10) | |
| demo.launch() | |