diff --git a/.github/workflows/check-installers.yml b/.github/workflows/check-installers.yml index 92dc13f396..9a3757e1bc 100644 --- a/.github/workflows/check-installers.yml +++ b/.github/workflows/check-installers.yml @@ -54,7 +54,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Run installer + - name: Run installer shell: pwsh run: | $hfTestRoot = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString()) diff --git a/docs/source/en/guides/cli.md b/docs/source/en/guides/cli.md index 9cd5e73e77..c377718c19 100644 --- a/docs/source/en/guides/cli.md +++ b/docs/source/en/guides/cli.md @@ -28,7 +28,7 @@ On macOS and Linux: On Windows: ```powershell ->>> powershell -c "irm https://hf.co/cli/install.ps1 | iex" +>>> powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex" ``` Once installed, you can check that the CLI is correctly setup: diff --git a/docs/source/en/installation.md b/docs/source/en/installation.md index 73382afc84..e2c19bb69c 100644 --- a/docs/source/en/installation.md +++ b/docs/source/en/installation.md @@ -116,7 +116,7 @@ curl -LsSf https://hf.co/cli/install.sh | sh On Windows: ```powershell -powershell -c "irm https://hf.co/cli/install.ps1 | iex" +powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex" ``` ## Install with conda diff --git a/src/huggingface_hub/_login.py b/src/huggingface_hub/_login.py index 4a358293c2..7700ab5b23 100644 --- a/src/huggingface_hub/_login.py +++ b/src/huggingface_hub/_login.py @@ -20,8 +20,8 @@ from typing import Optional from . import constants -from .cli._cli_utils import ANSI from .utils import ( + ANSI, capture_output, get_token, is_google_colab, diff --git a/src/huggingface_hub/cli/_cli_utils.py b/src/huggingface_hub/cli/_cli_utils.py index 9181dd5569..911aefbe4a 100644 --- a/src/huggingface_hub/cli/_cli_utils.py +++ b/src/huggingface_hub/cli/_cli_utils.py @@ -13,14 +13,21 @@ # limitations under the License. """Contains CLI utilities (styling, helpers).""" +import importlib.metadata import os +import time from enum import Enum -from typing import TYPE_CHECKING, Annotated, Optional, Union +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Optional import click import typer -from huggingface_hub import __version__ +from huggingface_hub import __version__, constants +from huggingface_hub.utils import ANSI, get_session, hf_raise_for_status, installation_method, logging + + +logger = logging.get_logger() if TYPE_CHECKING: @@ -34,58 +41,6 @@ def get_hf_api(token: Optional[str] = None) -> "HfApi": return HfApi(token=token, library_name="hf", library_version=__version__) -class ANSI: - """ - Helper for en.wikipedia.org/wiki/ANSI_escape_code - """ - - _bold = "\u001b[1m" - _gray = "\u001b[90m" - _red = "\u001b[31m" - _reset = "\u001b[0m" - _yellow = "\u001b[33m" - - @classmethod - def bold(cls, s: str) -> str: - return cls._format(s, cls._bold) - - @classmethod - def gray(cls, s: str) -> str: - return cls._format(s, cls._gray) - - @classmethod - def red(cls, s: str) -> str: - return cls._format(s, cls._bold + cls._red) - - @classmethod - def yellow(cls, s: str) -> str: - return cls._format(s, cls._yellow) - - @classmethod - def _format(cls, s: str, code: str) -> str: - if os.environ.get("NO_COLOR"): - # See https://no-color.org/ - return s - return f"{code}{s}{cls._reset}" - - -def tabulate(rows: list[list[Union[str, int]]], headers: list[str]) -> str: - """ - Inspired by: - - - stackoverflow.com/a/8356620/593036 - - stackoverflow.com/questions/9535954/printing-lists-as-tabular-data - """ - col_widths = [max(len(str(x)) for x in col) for col in zip(*rows, headers)] - row_format = ("{{:{}}} " * len(headers)).format(*col_widths) - lines = [] - lines.append(row_format.format(*headers)) - lines.append(row_format.format(*["-" * w for w in col_widths])) - for row in rows: - lines.append(row_format.format(*row)) - return "\n".join(lines) - - #### TYPER UTILS @@ -150,3 +105,66 @@ class RepoType(str, Enum): help="Git revision id which can be a branch name, a tag, or a commit hash.", ), ] + + +### PyPI VERSION CHECKER + + +def check_cli_update() -> None: + """ + Check whether a newer version of `huggingface_hub` is available on PyPI. + + If a newer version is found, notify the user and suggest updating. + If current version is a pre-release (e.g. `1.0.0.rc1`), or a dev version (e.g. `1.0.0.dev1`), no check is performed. + + This function is called at the entry point of the CLI. It only performs the check once every 24 hours, and any error + during the check is caught and logged, to avoid breaking the CLI. + """ + try: + _check_cli_update() + except Exception: + # We don't want the CLI to fail on version checks, no matter the reason. + logger.debug("Error while checking for CLI update.", exc_info=True) + + +def _check_cli_update() -> None: + current_version = importlib.metadata.version("huggingface_hub") + + # Skip if current version is a pre-release or dev version + if any(tag in current_version for tag in ["rc", "dev"]): + return + + # Skip if already checked in the last 24 hours + if os.path.exists(constants.CHECK_FOR_UPDATE_DONE_PATH): + mtime = os.path.getmtime(constants.CHECK_FOR_UPDATE_DONE_PATH) + if (time.time() - mtime) < 24 * 3600: + return + + # Touch the file to mark that we did the check now + Path(constants.CHECK_FOR_UPDATE_DONE_PATH).touch() + + # Check latest version from PyPI + response = get_session().get("https://pypi.org/pypi/huggingface_hub/json", timeout=2) + hf_raise_for_status(response) + data = response.json() + latest_version = data["info"]["version"] + + # If latest version is different from current, notify user + if current_version != latest_version: + method = installation_method() + if method == "brew": + update_command = "brew upgrade huggingface-cli" + elif method == "hf_installer" and os.name == "nt": + update_command = 'powershell -NoProfile -Command "iwr -useb https://hf.co/cli/install.ps1 | iex"' + elif method == "hf_installer": + update_command = "curl -LsSf https://hf.co/cli/install.sh | sh -" + else: # unknown => likely pip + update_command = "pip install -U huggingface_hub" + + click.echo( + ANSI.yellow( + f"A new version of huggingface_hub ({latest_version}) is available! " + f"You are using version {current_version}.\n" + f"To update, run: {ANSI.bold(update_command)}\n", + ) + ) diff --git a/src/huggingface_hub/cli/auth.py b/src/huggingface_hub/cli/auth.py index ce075b31f6..cb522c918c 100644 --- a/src/huggingface_hub/cli/auth.py +++ b/src/huggingface_hub/cli/auth.py @@ -39,8 +39,8 @@ from huggingface_hub.hf_api import whoami from .._login import auth_list, auth_switch, login, logout -from ..utils import get_stored_tokens, get_token, logging -from ._cli_utils import ANSI, TokenOpt, typer_factory +from ..utils import ANSI, get_stored_tokens, get_token, logging +from ._cli_utils import TokenOpt, typer_factory logger = logging.get_logger(__name__) diff --git a/src/huggingface_hub/cli/cache.py b/src/huggingface_hub/cli/cache.py index c57f9f207f..35f7540821 100644 --- a/src/huggingface_hub/cli/cache.py +++ b/src/huggingface_hub/cli/cache.py @@ -23,8 +23,8 @@ import typer -from ..utils import CachedRepoInfo, CachedRevisionInfo, CacheNotFound, HFCacheInfo, scan_cache_dir -from ._cli_utils import ANSI, tabulate, typer_factory +from ..utils import ANSI, CachedRepoInfo, CachedRevisionInfo, CacheNotFound, HFCacheInfo, scan_cache_dir, tabulate +from ._cli_utils import typer_factory # --- DELETE helpers (from delete_cache.py) --- diff --git a/src/huggingface_hub/cli/download.py b/src/huggingface_hub/cli/download.py index 84251cfe39..655e44d0f7 100644 --- a/src/huggingface_hub/cli/download.py +++ b/src/huggingface_hub/cli/download.py @@ -44,9 +44,9 @@ from huggingface_hub import logging from huggingface_hub._snapshot_download import snapshot_download from huggingface_hub.file_download import DryRunFileInfo, hf_hub_download -from huggingface_hub.utils import _format_size, disable_progress_bars, enable_progress_bars +from huggingface_hub.utils import _format_size, disable_progress_bars, enable_progress_bars, tabulate -from ._cli_utils import RepoIdArg, RepoTypeOpt, RevisionOpt, TokenOpt, tabulate +from ._cli_utils import RepoIdArg, RepoTypeOpt, RevisionOpt, TokenOpt logger = logging.get_logger(__name__) diff --git a/src/huggingface_hub/cli/hf.py b/src/huggingface_hub/cli/hf.py index bb8b3b80d0..8306eff084 100644 --- a/src/huggingface_hub/cli/hf.py +++ b/src/huggingface_hub/cli/hf.py @@ -13,7 +13,7 @@ # limitations under the License. -from huggingface_hub.cli._cli_utils import typer_factory +from huggingface_hub.cli._cli_utils import check_cli_update, typer_factory from huggingface_hub.cli.auth import auth_cli from huggingface_hub.cli.cache import cache_cli from huggingface_hub.cli.download import download @@ -52,6 +52,7 @@ def main(): logging.set_verbosity_info() + check_cli_update() app() diff --git a/src/huggingface_hub/cli/repo.py b/src/huggingface_hub/cli/repo.py index 793c1247c8..bb67ba9172 100644 --- a/src/huggingface_hub/cli/repo.py +++ b/src/huggingface_hub/cli/repo.py @@ -27,10 +27,9 @@ import typer from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError, RevisionNotFoundError -from huggingface_hub.utils import logging +from huggingface_hub.utils import ANSI, logging from ._cli_utils import ( - ANSI, PrivateOpt, RepoIdArg, RepoType, diff --git a/src/huggingface_hub/cli/upload_large_folder.py b/src/huggingface_hub/cli/upload_large_folder.py index fd5d6bb08e..af4fc55836 100644 --- a/src/huggingface_hub/cli/upload_large_folder.py +++ b/src/huggingface_hub/cli/upload_large_folder.py @@ -20,9 +20,9 @@ import typer from huggingface_hub import logging -from huggingface_hub.utils import disable_progress_bars +from huggingface_hub.utils import ANSI, disable_progress_bars -from ._cli_utils import ANSI, PrivateOpt, RepoIdArg, RepoType, RepoTypeOpt, RevisionOpt, TokenOpt, get_hf_api +from ._cli_utils import PrivateOpt, RepoIdArg, RepoType, RepoTypeOpt, RevisionOpt, TokenOpt, get_hf_api logger = logging.get_logger(__name__) diff --git a/src/huggingface_hub/constants.py b/src/huggingface_hub/constants.py index 2ca29cf294..90ba0a5889 100644 --- a/src/huggingface_hub/constants.py +++ b/src/huggingface_hub/constants.py @@ -163,6 +163,10 @@ def _as_int(value: Optional[str]) -> Optional[int]: HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE") or os.environ.get("TRANSFORMERS_OFFLINE")) +# File created to mark that the version check has been done. +# Check is performed once per 24 hours at most. +CHECK_FOR_UPDATE_DONE_PATH = os.path.join(HF_HOME, ".check_for_update_done") + # If set, log level will be set to DEBUG and all requests made to the Hub will be logged # as curl commands for reproducibility. HF_DEBUG = _is_true(os.environ.get("HF_DEBUG")) diff --git a/src/huggingface_hub/utils/__init__.py b/src/huggingface_hub/utils/__init__.py index 7817bfe0d4..bccc01174d 100644 --- a/src/huggingface_hub/utils/__init__.py +++ b/src/huggingface_hub/utils/__init__.py @@ -85,6 +85,7 @@ get_tensorboard_version, get_tf_version, get_torch_version, + installation_method, is_aiohttp_available, is_colab_enterprise, is_fastai_available, @@ -109,6 +110,7 @@ from ._safetensors import SafetensorsFileMetadata, SafetensorsRepoMetadata, TensorInfo from ._subprocess import capture_output, run_interactive_subprocess, run_subprocess from ._telemetry import send_telemetry +from ._terminal import ANSI, tabulate from ._typing import is_jsonable, is_simple_optional_type, unwrap_simple_optional_type from ._validators import validate_hf_hub_args, validate_repo_id from ._xet import ( diff --git a/src/huggingface_hub/utils/_cache_manager.py b/src/huggingface_hub/utils/_cache_manager.py index 2d927cf278..3ec03f35e8 100644 --- a/src/huggingface_hub/utils/_cache_manager.py +++ b/src/huggingface_hub/utils/_cache_manager.py @@ -24,9 +24,9 @@ from huggingface_hub.errors import CacheNotFound, CorruptedCacheException -from ..cli._cli_utils import tabulate from ..constants import HF_HUB_CACHE from . import logging +from ._terminal import tabulate logger = logging.get_logger(__name__) diff --git a/src/huggingface_hub/utils/_http.py b/src/huggingface_hub/utils/_http.py index b172379417..f98dfda517 100644 --- a/src/huggingface_hub/utils/_http.py +++ b/src/huggingface_hub/utils/_http.py @@ -716,9 +716,9 @@ def _curlify(request: httpx.Request) -> str: flat_parts = [] for k, v in parts: if k: - flat_parts.append(quote(k)) + flat_parts.append(quote(str(k))) if v: - flat_parts.append(quote(v)) + flat_parts.append(quote(str(v))) return " ".join(flat_parts) diff --git a/src/huggingface_hub/utils/_runtime.py b/src/huggingface_hub/utils/_runtime.py index dd390df87c..445be52baf 100644 --- a/src/huggingface_hub/utils/_runtime.py +++ b/src/huggingface_hub/utils/_runtime.py @@ -19,7 +19,8 @@ import platform import sys import warnings -from typing import Any +from pathlib import Path +from typing import Any, Literal from .. import __version__, constants @@ -322,6 +323,49 @@ def is_colab_enterprise() -> bool: return os.environ.get("VERTEX_PRODUCT") == "COLAB_ENTERPRISE" +# Check how huggingface_hub has been installed + + +def installation_method() -> Literal["brew", "hf_installer", "unknown"]: + """Return the installation method of the current environment. + + - "hf_installer" if installed via the official installer script + - "brew" if installed via Homebrew + - "unknown" otherwise + """ + if _is_brew_installation(): + return "brew" + elif _is_hf_installer_installation(): + return "hf_installer" + else: + return "unknown" + + +def _is_brew_installation() -> bool: + """Check if running from a Homebrew installation. + + Note: AI-generated by Claude. + """ + exe_path = Path(sys.executable).resolve() + exe_str = str(exe_path) + + # Check common Homebrew paths + # /opt/homebrew (Apple Silicon), /usr/local (Intel) + return "/Cellar/" in exe_str or "/opt/homebrew/" in exe_str or exe_str.startswith("/usr/local/Cellar/") + + +def _is_hf_installer_installation() -> bool: + """Return `True` if the current environment was set up via the official hf installer script. + + i.e. using one of + curl -LsSf https://hf.co/cli/install.sh | sh + powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex" + """ + venv = sys.prefix # points to venv root if active + marker = Path(venv) / ".hf_installer_marker" + return marker.exists() + + def dump_environment_info() -> dict[str, Any]: """Dump information about the machine to help debugging issues. @@ -366,6 +410,9 @@ def dump_environment_info() -> dict[str, Any]: except Exception: pass + # How huggingface_hub has been installed? + info["Installation method"] = installation_method() + # Installed dependencies info["Torch"] = get_torch_version() info["httpx"] = get_httpx_version() diff --git a/src/huggingface_hub/utils/_terminal.py b/src/huggingface_hub/utils/_terminal.py new file mode 100644 index 0000000000..6463b4b3cc --- /dev/null +++ b/src/huggingface_hub/utils/_terminal.py @@ -0,0 +1,69 @@ +# Copyright 2025 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Contains utilities to print stuff to the terminal (styling, helpers).""" + +import os +from typing import Union + + +class ANSI: + """ + Helper for en.wikipedia.org/wiki/ANSI_escape_code + """ + + _bold = "\u001b[1m" + _gray = "\u001b[90m" + _red = "\u001b[31m" + _reset = "\u001b[0m" + _yellow = "\u001b[33m" + + @classmethod + def bold(cls, s: str) -> str: + return cls._format(s, cls._bold) + + @classmethod + def gray(cls, s: str) -> str: + return cls._format(s, cls._gray) + + @classmethod + def red(cls, s: str) -> str: + return cls._format(s, cls._bold + cls._red) + + @classmethod + def yellow(cls, s: str) -> str: + return cls._format(s, cls._yellow) + + @classmethod + def _format(cls, s: str, code: str) -> str: + if os.environ.get("NO_COLOR"): + # See https://no-color.org/ + return s + return f"{code}{s}{cls._reset}" + + +def tabulate(rows: list[list[Union[str, int]]], headers: list[str]) -> str: + """ + Inspired by: + + - stackoverflow.com/a/8356620/593036 + - stackoverflow.com/questions/9535954/printing-lists-as-tabular-data + """ + col_widths = [max(len(str(x)) for x in col) for col in zip(*rows, headers)] + row_format = ("{{:{}}} " * len(headers)).format(*col_widths) + lines = [] + lines.append(row_format.format(*headers)) + lines.append(row_format.format(*["-" * w for w in col_widths])) + for row in rows: + lines.append(row_format.format(*row)) + return "\n".join(lines) diff --git a/tests/test_utils_cli.py b/tests/test_utils_terminal.py similarity index 97% rename from tests/test_utils_cli.py rename to tests/test_utils_terminal.py index 5e6cdedeab..5b5f39bf5e 100644 --- a/tests/test_utils_cli.py +++ b/tests/test_utils_terminal.py @@ -2,7 +2,7 @@ import unittest from unittest import mock -from huggingface_hub.cli._cli_utils import ANSI, tabulate +from huggingface_hub.utils._terminal import ANSI, tabulate class TestCLIUtils(unittest.TestCase): diff --git a/utils/installers/install.ps1 b/utils/installers/install.ps1 index 8fd82e39d9..cb7f507cb8 100644 --- a/utils/installers/install.ps1 +++ b/utils/installers/install.ps1 @@ -1,5 +1,5 @@ # Hugging Face CLI Installer for Windows -# Usage: powershell -c "irm https://hf.co/cli/install.ps1 | iex" +# Usage: powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex" # Or: curl -LsSf https://hf.co/cli/install.ps1 | pwsh - <# @@ -223,6 +223,10 @@ function New-VirtualEnvironment { } if (-not $?) { throw "Failed to create virtual environment" } + # Mark this installation as installer-managed + $markerFile = Join-Path $VENV_DIR ".hf_installer_marker" + New-Item -Path $markerFile -ItemType File -Force | Out-Null + # Use the venv's python -m pip for deterministic upgrades $script:VenvPython = Join-Path $SCRIPTS_DIR "python.exe" Write-Log "Upgrading pip..." diff --git a/utils/installers/install.sh b/utils/installers/install.sh index d8030ea585..011fcfb30c 100755 --- a/utils/installers/install.sh +++ b/utils/installers/install.sh @@ -180,7 +180,7 @@ detect_os() { # Install Python if not available python_version_supported() { - "$1" - <<'PY' >/dev/null 2>&1 + "$1" <<'PY' >/dev/null 2>&1 import sys sys.exit(0 if sys.version_info >= (3, 9) else 1) PY @@ -268,6 +268,9 @@ create_venv() { run_command "Failed to create virtual environment at $VENV_DIR" "$PYTHON_CMD" -m venv "$VENV_DIR" + # Mark this installation as installer-managed + touch "$VENV_DIR/.hf_installer_marker" + # Use the venv python for pip management log_info "Upgrading pip..." run_command "Failed to upgrade pip" "$VENV_DIR/bin/python" -m pip install --upgrade pip