Source code for interpretune.analysis.ops.hub_manager

"""Hub manager for downloading and uploading analysis operation definitions."""

from __future__ import annotations
from pathlib import Path
from dataclasses import dataclass

from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.errors import RepositoryNotFoundError
from huggingface_hub.constants import REPO_TYPE_MODEL

from interpretune.analysis import IT_ANALYSIS_HUB_CACHE
from interpretune.analysis.ops.compiler.cache_manager import _get_latest_revision
from interpretune.utils.logging import rank_zero_debug, rank_zero_warn, rank_zero_info


@dataclass
class HubOpCollection:
    """Information about an analysis operation collection from the Hub."""

    repo_id: str
    username: str
    repo_name: str
    local_path: Path
    revision: str = "main"

    @classmethod
    def from_repo_id(cls, repo_id: str, local_path: Path, revision: str = "main") -> "HubOpCollection":
        """Create HubOpCollection from a repo_id like 'username/repo-name'."""
        if "/" not in repo_id:
            raise ValueError(f"Invalid repo_id format: {repo_id}. Expected 'username/repo-name'")

        username, repo_name = repo_id.split("/", 1)
        return cls(repo_id=repo_id, username=username, repo_name=repo_name, local_path=local_path, revision=revision)

    @property
    def namespace_prefix(self) -> str:
        """Get the namespace prefix for operations in this collection."""
        # Extract collection name from repo name
        collection_name = self.repo_name
        return f"{self.username}.{collection_name}"


[docs] class HubAnalysisOpManager: """Manages downloading and uploading analysis operation definitions from/to Hugging Face Hub.""" def __init__(self, cache_dir: Path | None = None, token: str | None = None): """Initialize the hub manager. Args: cache_dir: Directory for caching hub downloads. Defaults to IT_ANALYSIS_HUB_CACHE. token: HuggingFace token for authentication. If None, uses HF_TOKEN env var. """ self.cache_dir = cache_dir or IT_ANALYSIS_HUB_CACHE self.api = HfApi(token=token) rank_zero_debug(f"Initialized HubAnalysisOpManager with cache_dir: {self.cache_dir}")
[docs] def download_ops(self, repo_id: str, revision: str = "main", force_download: bool = False) -> HubOpCollection: """Download analysis operations from HF Hub. Args: repo_id: Repository ID in format 'username/repo-name' revision: Git revision to download (default: "main") force_download: Whether to force re-download even if cached Returns: HubOpCollection with information about the downloaded collection Raises: RepositoryNotFoundError: If the repository doesn't exist ValueError: If repo_id format is invalid """ rank_zero_info(f"Downloading analysis ops from {repo_id} (revision: {revision})") try: # Download the repository to cache local_path = Path( snapshot_download( repo_id=repo_id, repo_type=REPO_TYPE_MODEL, cache_dir=str(self.cache_dir), revision=revision, force_download=force_download, token=self.api.token, ) ) collection = HubOpCollection.from_repo_id(repo_id, local_path, revision) rank_zero_debug(f"Downloaded {repo_id} to {local_path}") return collection except RepositoryNotFoundError: rank_zero_warn(f"Repository {repo_id} not found on Hugging Face Hub") raise except Exception as e: rank_zero_warn(f"Failed to download {repo_id}: {e}") raise
[docs] def upload_ops( self, local_dir: Path, repo_id: str, commit_message: str = "Upload analysis operations", revision: str = "main", create_pr: bool = False, private: bool = False, clean_existing: bool = False, delete_patterns: list[str] | None = None, ) -> str: """Upload analysis operations to HuggingFace Hub. Args: local_dir: Local directory containing operations to upload repo_id: Repository ID on HuggingFace Hub commit_message: Commit message for the upload revision: Git revision/branch to upload to create_pr: Whether to create a pull request private: Whether the repository should be private clean_existing: Whether to remove existing operation files before upload delete_patterns: Custom patterns for files to delete (overrides default when clean_existing=True) Returns: Commit URL or PR URL if create_pr=True """ # Validation if "/" not in repo_id: raise ValueError(f"Invalid repo_id format: {repo_id}. Expected 'username/repo-name'") if not local_dir.exists(): raise ValueError(f"Local directory does not exist: {local_dir}") # Check if repository exists and create if it doesn't repo_exists = False initial_repo_sha = None files_to_delete = [] try: repo_info = self.api.repo_info(repo_id, repo_type=REPO_TYPE_MODEL) repo_exists = True initial_repo_sha = repo_info.sha # Get current commit hash rank_zero_debug(f"Repository {repo_id} already exists") except RepositoryNotFoundError: rank_zero_info(f"Creating new repository: {repo_id}") self.api.create_repo(repo_id=repo_id, repo_type=REPO_TYPE_MODEL, private=private) repo_exists = False # Just created, so no existing files to worry about # Default delete pattern for operation files DEFAULT_DELETE_PATTERNS = ["*.py", "*.yaml"] delete_patterns_to_use = None if clean_existing and repo_exists: # Only check for existing files if repo already existed delete_patterns_to_use = delete_patterns if delete_patterns is not None else DEFAULT_DELETE_PATTERNS # Check for existing files that would be deleted and store for potential warning try: existing_files = self.api.list_repo_files(repo_id=repo_id, repo_type=REPO_TYPE_MODEL, revision=revision) # Filter files that match delete patterns import fnmatch for file_path in existing_files: for pattern in delete_patterns_to_use: if fnmatch.fnmatch(file_path, pattern): files_to_delete.append(file_path) break except Exception: # If we can't check existing files, that's fine - upload_folder will handle it pass elif clean_existing and not repo_exists: # Repository was just created, so we can still set delete patterns if provided delete_patterns_to_use = delete_patterns if delete_patterns is not None else DEFAULT_DELETE_PATTERNS try: rank_zero_debug(f"Uploading analysis ops from {local_dir} to {repo_id}") commit_info = self.api.upload_folder( folder_path=str(local_dir), repo_id=repo_id, repo_type=REPO_TYPE_MODEL, commit_message=commit_message, revision=revision, create_pr=create_pr, delete_patterns=delete_patterns_to_use, ) commit_issued = all((initial_repo_sha, hasattr(commit_info, "oid"), commit_info.oid != initial_repo_sha)) # Only issue the warning if files were actually deleted (commit hash changed) and files were identified for # deletion if files_to_delete and commit_issued: rank_zero_warn( f"clean_existing=True removed {len(files_to_delete)} existing files " f"matching patterns {delete_patterns_to_use} from repository '{repo_id}'. " f"Files removed: {files_to_delete[:10]}" f"{'...' if len(files_to_delete) > 10 else ''}", stacklevel=2, ) if commit_issued: rank_zero_info( f"Successfully uploaded to {repo_id}, previous sha: {initial_repo_sha}, new sha: {commit_info.oid}" ) return commit_info.oid except Exception as e: rank_zero_warn(f"Failed to upload to {repo_id}: {e}") raise
[docs] def list_available_collections(self, username: str | None = None) -> list[str]: """List available analysis operation collections on the Hub. Args: username: Filter by username (optional) Returns: List of repository IDs for analysis operation collections """ try: # Use HFApi to search for models using tag-style interpretune filter. repos = [] models = self.api.list_models(filter="interpretune", cardData=True) for model in models: repo_id = model.modelId # type: ignore[attr-defined] # HuggingFace ModelInfo compatibility if username and not repo_id.startswith(f"{username}/"): continue repos.append(repo_id) rank_zero_debug(f"Found {len(repos)} analysis operation collections") return repos except Exception as e: rank_zero_warn(f"Failed to list collections: {e}") return []
[docs] def discover_hub_ops(self, search_patterns: list[str] | None = None) -> list[HubOpCollection]: """Discover and cache analysis operations from the Hub. Args: search_patterns: List of repo_id patterns to search for (default: auto-discover) Returns: List of HubOpCollection objects for discovered collections """ collections = [] if search_patterns: # Use provided patterns for pattern in search_patterns: try: collection = self.download_ops(pattern) collections.append(collection) except Exception as e: rank_zero_warn(f"Failed to download {pattern}: {e}") else: # Auto-discover available collections available_repos = self.list_available_collections() for repo_id in available_repos: try: collection = self.download_ops(repo_id) collections.append(collection) except Exception as e: rank_zero_warn(f"Failed to download {repo_id}: {e}") rank_zero_info(f"Discovered {len(collections)} hub operation collections") return collections
[docs] def get_cached_collections(self) -> list[HubOpCollection]: """Get analysis operation collections that are already cached locally. Returns: List of HubOpCollection objects for cached collections """ collections = [] if not self.cache_dir.exists(): return collections # Use HuggingFace cache manager for robust scanning try: from huggingface_hub.utils._cache_manager import scan_cache_dir cache_info = scan_cache_dir(self.cache_dir) for repo in cache_info.repos: # Only consider model repositories if repo.repo_type != "model": continue # Find the latest revision for this repo (preferring 'main' ref) latest_revision = _get_latest_revision(repo) if latest_revision is None: continue # Check if this revision has any YAML files (operation definitions) has_yaml_files = any( file_info.file_name.endswith((".yaml", ".yml")) for file_info in latest_revision.files ) if has_yaml_files: # Create HubOpCollection from the repo info collection = HubOpCollection.from_repo_id( repo.repo_id, latest_revision.snapshot_path, revision=latest_revision.commit_hash ) collections.append(collection) except Exception as e: rank_zero_warn(f"Failed to scan hub cache using scan_cache_dir: {e}") rank_zero_debug(f"Found {len(collections)} cached hub collections") return collections