Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2021 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -16,6 +15,7 @@
# ruff: noqa: F401
from huggingface_hub.errors import (
BadRequestError,
BucketNotFoundError,
CacheNotFound,
CorruptedCacheException,
DisabledRepoError,
@ -47,10 +47,12 @@ from ._cache_manager import (
)
from ._chunk_utils import chunk_iterable
from ._datetime import parse_datetime
from ._detect_agent import detect_agent, is_agent
from ._experimental import experimental
from ._fixes import SoftTemporaryDirectory, WeakFileLock, yaml_dump
from ._git_credential import list_credential_helpers, set_git_credential, unset_git_credential
from ._headers import build_hf_headers, get_token_to_send
from ._hf_uris import HfMount, HfUri, is_hf_uri, parse_hf_mount, parse_hf_uri
from ._http import (
ASYNC_CLIENT_FACTORY_T,
CLIENT_FACTORY_T,
@ -110,7 +112,7 @@ from ._runtime import (
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 ._terminal import ANSI, StatusLine, 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 (
@ -126,6 +128,7 @@ from .tqdm import (
disable_progress_bars,
enable_progress_bars,
is_tqdm_disabled,
silent_tqdm,
tqdm,
tqdm_stream_file,
)

View file

@ -14,42 +14,72 @@
"""Contains a helper to get the token from machine (env variable, secret or config file)."""
import configparser
import io
import logging
import os
import time
import warnings
from pathlib import Path
from threading import Lock
from typing import Optional
from typing import TypedDict
from .. import constants
from ..errors import OIDCError
from ._runtime import is_colab_enterprise, is_google_colab
_SECRET_FILE_MODE = 0o600
_SECRET_DIR_MODE = 0o700
def _write_secret(path: Path, content: str) -> None:
"""Write content to file, restricting both the file and its parent directory to owner-only on POSIX systems."""
path.parent.mkdir(parents=True, exist_ok=True, mode=_SECRET_DIR_MODE)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _SECRET_FILE_MODE)
with os.fdopen(fd, "w") as f:
f.write(content)
try:
path.chmod(_SECRET_FILE_MODE)
path.parent.chmod(_SECRET_DIR_MODE)
except (OSError, NotImplementedError):
# Windows does not support POSIX modes; chmod() will raise. Best-effort.
pass
_IS_GOOGLE_COLAB_CHECKED = False
_GOOGLE_COLAB_SECRET_LOCK = Lock()
_GOOGLE_COLAB_SECRET: Optional[str] = None
_GOOGLE_COLAB_SECRET: str | None = None
logger = logging.getLogger(__name__)
def get_token() -> Optional[str]:
def get_token() -> str | None:
"""
Get token if user is logged in.
Note: in most cases, you should use [`huggingface_hub.utils.build_hf_headers`] instead. This method is only useful
if you want to retrieve the token for other purposes than sending an HTTP request.
Token is retrieved in priority from the `HF_TOKEN` environment variable. Otherwise, we read the token file located
in the Hugging Face home folder. Returns None if user is not logged in. To log in, use [`login`] or
If `HF_OIDC_RESOURCE` is set (Trusted Publishers, typically in CI), a short-lived token obtained via OIDC token
exchange takes precedence. Otherwise the token is retrieved from the `HF_TOKEN` environment variable, then from the
token file in the Hugging Face home folder. Returns None if user is not logged in. To log in, use [`login`] or
`hf auth login`.
Note: if `HF_OIDC_RESOURCE` is set but the OIDC token exchange fails, this raises instead of returning `None`,
opting into OIDC is explicit, so a failure surfaces as a clear error rather than a silent fallback.
Returns:
`str` or `None`: The token, `None` if it doesn't exist.
"""
return _get_token_from_google_colab() or _get_token_from_environment() or _get_token_from_file()
return (
_get_token_from_oidc()
or _get_token_from_environment()
or _get_token_from_file()
or _get_token_from_google_colab()
)
def _get_token_from_google_colab() -> Optional[str]:
def _get_token_from_google_colab() -> str | None:
"""Get token from Google Colab secrets vault using `google.colab.userdata.get(...)`.
Token is read from the vault only once per session and then stored in a global variable to avoid re-requesting
@ -113,18 +143,82 @@ def _get_token_from_google_colab() -> Optional[str]:
return _GOOGLE_COLAB_SECRET
def _get_token_from_environment() -> Optional[str]:
def _get_token_from_environment() -> str | None:
# `HF_TOKEN` has priority (keep `HUGGING_FACE_HUB_TOKEN` for backward compatibility)
return _clean_token(os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"))
def _get_token_from_file() -> Optional[str]:
def _get_token_from_file() -> str | None:
try:
return _clean_token(Path(constants.HF_TOKEN_PATH).read_text())
except FileNotFoundError:
return None
class _OidcTokenCache(TypedDict):
resource: str
token: str
expires_at: float # monotonic clock value after which the cached token must be re-exchanged
# Cache for the OIDC-exchanged token: re-exchanging on every `get_token()` call would be wasteful,
# and re-exchanging shortly before expiry transparently keeps long-running jobs authenticated.
_OIDC_TOKEN_LOCK = Lock()
_OIDC_TOKEN_CACHE: _OidcTokenCache | None = None
_OIDC_REFRESH_MARGIN = 300 # re-exchange this many seconds before the token actually expires
def _get_token_from_oidc() -> str | None:
"""Get a short-lived OIDC token in CI (Trusted Publishers).
Enabled by setting `HF_OIDC_RESOURCE`, which scopes the token to a repo or user.
The ID token is read from `HF_OIDC_ID_TOKEN` if available, or minted from a supported CI provider (e.g. GitHub Actions).
Returns `None` when OIDC is not enabled.
If enabled, any failure is raised explicitly rather than falling back silently.
See `huggingface_hub._oidc` and https://huggingface.co/docs/hub/trusted-publishers.
"""
resource = os.environ.get("HF_OIDC_RESOURCE")
if not resource:
return None
from .._oidc import detect_provider, oidc_login
global _OIDC_TOKEN_CACHE
with _OIDC_TOKEN_LOCK:
now = time.monotonic()
if (
_OIDC_TOKEN_CACHE is not None
and _OIDC_TOKEN_CACHE["resource"] == resource
and now < _OIDC_TOKEN_CACHE["expires_at"]
):
return _OIDC_TOKEN_CACHE["token"]
# An explicit id token (any provider) takes precedence; otherwise mint from a detected one.
subject_token = os.environ.get("HF_OIDC_ID_TOKEN") or None
if subject_token is None and detect_provider() is None:
raise OIDCError(
"HF_OIDC_RESOURCE is set but no OIDC id token is available: not running in a supported "
"CI provider (github) and HF_OIDC_ID_TOKEN is not set. Set HF_OIDC_ID_TOKEN to the id "
"token minted by your CI provider, or unset HF_OIDC_RESOURCE."
)
result = oidc_login(resource=resource, subject_token=subject_token)
token = result["access_token"]
expires_in = int(result.get("expires_in", 3600))
# A pre-supplied HF_OIDC_ID_TOKEN can't be re-minted, so refreshing early is pointless (the id
# token is likely already expired by then): cache for the full lifetime. Only the auto-minted
# path can refresh, so only it gets the safety margin.
margin = 0 if subject_token is not None else _OIDC_REFRESH_MARGIN
_OIDC_TOKEN_CACHE = {
"resource": resource,
"token": token,
"expires_at": now + max(expires_in - margin, 0),
}
return token
def get_stored_tokens() -> dict[str, str]:
"""
Returns the parsed INI file containing the access tokens.
@ -163,12 +257,12 @@ def _save_stored_tokens(stored_tokens: dict[str, str]) -> None:
config.add_section(token_name)
config.set(token_name, "hf_token", stored_tokens[token_name])
stored_tokens_path.parent.mkdir(parents=True, exist_ok=True)
with stored_tokens_path.open("w") as config_file:
config.write(config_file)
buf = io.StringIO()
config.write(buf)
_write_secret(stored_tokens_path, buf.getvalue())
def _get_token_by_name(token_name: str) -> Optional[str]:
def _get_token_by_name(token_name: str) -> str | None:
"""
Get the token by name.
@ -204,7 +298,7 @@ def _save_token(token: str, token_name: str) -> None:
logger.info(f"The token `{token_name}` has been saved to {tokens_path}")
def _clean_token(token: Optional[str]) -> Optional[str]:
def _clean_token(token: str | None) -> str | None:
"""Clean token by removing trailing and leading spaces and newlines.
If token is an empty string, return None.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2019-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -13,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from pathlib import Path
from typing import Union
from ..constants import HF_ASSETS_CACHE
@ -23,7 +21,7 @@ def cached_assets_path(
namespace: str = "default",
subfolder: str = "default",
*,
assets_dir: Union[str, Path, None] = None,
assets_dir: str | Path | None = None,
) -> Path:
"""Return a folder path to cache arbitrary files.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -19,7 +18,7 @@ import shutil
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Optional, Union
from typing import Literal
from huggingface_hub.errors import CacheNotFound, CorruptedCacheException
@ -509,7 +508,7 @@ class HFCacheInfo:
[
repo.repo_id,
repo.repo_type,
"{:>12}".format(repo.size_on_disk_str),
f"{repo.size_on_disk_str:>12}",
repo.nb_files,
repo.last_accessed_str,
repo.last_modified_str,
@ -536,7 +535,7 @@ class HFCacheInfo:
repo.repo_id,
repo.repo_type,
revision.commit_hash,
"{:>12}".format(revision.size_on_disk_str),
f"{revision.size_on_disk_str:>12}",
revision.nb_files,
revision.last_modified_str,
", ".join(sorted(revision.refs)),
@ -558,7 +557,7 @@ class HFCacheInfo:
)
def scan_cache_dir(cache_dir: Optional[Union[str, Path]] = None) -> HFCacheInfo:
def scan_cache_dir(cache_dir: str | Path | None = None) -> HFCacheInfo:
"""Scan the entire HF cache-system and return a [`~HFCacheInfo`] structure.
Use `scan_cache_dir` in order to programmatically scan your cache-system. The cache
@ -658,6 +657,8 @@ def scan_cache_dir(cache_dir: Optional[Union[str, Path]] = None) -> HFCacheInfo:
for repo_path in cache_dir.iterdir():
if repo_path.name == ".locks": # skip './.locks/' folder
continue
if repo_path.name == "CACHEDIR.TAG": # skip CACHEDIR.TAG file
continue
try:
repos.add(_scan_cached_repo(repo_path))
except CorruptedCacheException as e:
@ -769,7 +770,7 @@ def _scan_cached_repo(repo_path: Path) -> CachedRepoInfo:
files=frozenset(cached_files),
refs=frozenset(refs_by_hash.pop(revision_path.name, set())),
size_on_disk=sum(
blob_stats[blob_path].st_size for blob_path in set(file.blob_path for file in cached_files)
blob_stats[blob_path].st_size for blob_path in {file.blob_path for file in cached_files}
),
snapshot_path=revision_path,
last_modified=revision_last_modified,

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -15,7 +14,8 @@
"""Contains a utility to iterate by chunks over an iterator."""
import itertools
from typing import Iterable, TypeVar
from collections.abc import Iterable
from typing import TypeVar
T = TypeVar("T")

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");

View file

@ -1,7 +1,7 @@
import warnings
from collections.abc import Iterable
from functools import wraps
from inspect import Parameter, signature
from typing import Iterable, Optional
def _deprecate_positional_args(*, version: str):
@ -53,7 +53,7 @@ def _deprecate_arguments(
*,
version: str,
deprecated_args: Iterable[str],
custom_message: Optional[str] = None,
custom_message: str | None = None,
):
"""Decorator to issue warnings when using deprecated arguments.
@ -105,7 +105,7 @@ def _deprecate_arguments(
return _inner_deprecate_positional_args
def _deprecate_method(*, version: str, message: Optional[str] = None):
def _deprecate_method(*, version: str, message: str | None = None):
"""Decorator to issue warnings when using a deprecated method.
Args:

View file

@ -0,0 +1,204 @@
# Copyright 2026 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.
"""Detect whether the process is being invoked by an AI coding agent.
Detection is based on environment variables that AI agents set in their shell
sessions. `AI_AGENT` and `AGENT` are treated as a universal standard (any
tool can set its harness id there); the remaining checks are tool-specific and
ordered by priority (first match wins).
The list of known harnesses is maintained on the Hub and exposed at
`{ENDPOINT}/api/agent-harnesses`. We fetch it at most once a day and cache it
locally so the list can be updated without requiring a new client release.
Detection is entirely best-effort: there is no hardcoded list of harnesses. When
the registry cannot be fetched (and no cached copy is available), detection simply
reports "no agent". Any error while fetching/reading the registry is swallowed
detection must never make a process fail.
More details: https://huggingface.co/docs/hub/agents-overview#register-your-agent-harness
"""
import json
import os
import time
from pathlib import Path
from typing import Optional, TypedDict
from .. import constants
from . import logging
logger = logging.get_logger(__name__)
# Refresh the cached registry at most once every 24 hours.
_REGISTRY_TTL_SECONDS = 24 * 3600
# Short timeout: fetching the registry is best-effort telemetry, never block the caller for long.
_REGISTRY_FETCH_TIMEOUT = 3
class HarnessInfo(TypedDict, total=False):
"""A single harness entry. `envVars` maps an env var name to a match pattern (see `_env_vars_match`)."""
envVars: dict[str, str]
class Registry(TypedDict):
"""The agent harness registry, as served by `{ENDPOINT}/api/agent-harnesses`."""
standardEnvVars: list[str]
harnesses: dict[str, HarnessInfo]
# Empty registry: detection is disabled (no agent ever detected). Used when the
# Hub is unreachable and no cached copy is available.
_EMPTY_REGISTRY: Registry = {"standardEnvVars": [], "harnesses": {}}
# In-process cache of the resolved registry. Populated lazily on first detection.
_registry: Registry | None = None
def detect_agent() -> Optional[str]:
"""Return the id of the detected AI agent harness or `None`.
Harnesses are checked in registry order; for each one we match its env var
pattern(s) and, failing that, the standard `AI_AGENT` / `AGENT` vars
against the harness id. The first match wins. When a standard var is set to
an unrecognized value, `"unknown"` is returned.
"""
registry = _get_registry()
standard_vars = registry.get("standardEnvVars") or []
harnesses = registry.get("harnesses") or {}
for harness_id, info in harnesses.items():
env_vars = (info or {}).get("envVars")
if env_vars and _env_vars_match(env_vars):
return harness_id
for var in standard_vars:
if os.environ.get(var, "").strip() == harness_id:
return harness_id
# No harness matched but a standard var is set => unrecognized agent.
lowercased_harnesses = {k.lower() for k in harnesses.keys()}
for var in standard_vars:
if value := os.environ.get(var, "").strip().lower():
if value in lowercased_harnesses:
return value
return "unknown"
return None
def is_agent() -> bool:
"""Return `True` if the process is being invoked by an AI coding agent."""
return detect_agent() is not None
def _env_vars_match(env_vars: dict[str, str]) -> bool:
"""Return `True` if any `(var, pattern)` from the harness matches the environment.
Supported patterns:
- `"*"`: the variable is set to any non-empty value
- `"<value>"`: the variable equals this exact value
"""
for var, pattern in env_vars.items():
value = os.environ.get(var)
if not value:
continue
if pattern == "*":
return True
if value == pattern:
return True
return False
def _get_registry() -> Registry:
"""Return the harness registry, loading (and caching in-process) on first call.
Best-effort: any unexpected error degrades to an empty registry so detection
never raises.
"""
global _registry
if _registry is None:
try:
_registry = _load_registry()
except Exception:
logger.debug("Could not resolve agent harnesses registry.", exc_info=True)
_registry = _EMPTY_REGISTRY
return _registry
def _load_registry() -> Registry:
"""Resolve the registry from the local cache or the Hub.
No hardcoded list: if the Hub is unreachable and no cached copy exists, an
empty registry is returned (i.e. no agent is detected).
"""
path = constants.AGENT_HARNESSES_PATH
# 1. Use the cached file if it was refreshed within the last 24 hours.
if cached := _read_cached_registry(path, max_age=_REGISTRY_TTL_SECONDS):
return cached
# 2. Otherwise refresh it from the Hub and persist it for next time.
if (fetched := _fetch_registry()) is not None:
_write_cached_registry(path, fetched)
return fetched
# 3. Fetch failed: reuse a stale cache if available, else give up (no detection).
if stale := _read_cached_registry(path, max_age=None):
return stale
return _EMPTY_REGISTRY
def _read_cached_registry(path: str, max_age: int | None) -> Registry | None:
"""Return the cached registry, or `None` if missing/stale/unreadable."""
try:
if not os.path.exists(path):
return None
if max_age is not None and (time.time() - os.path.getmtime(path)) >= max_age:
return None
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
logger.debug("Could not read cached agent harnesses registry.", exc_info=True)
return None
def _write_cached_registry(path: str, registry: Registry) -> None:
try:
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(registry, f)
except Exception:
logger.debug("Could not cache agent harnesses registry.", exc_info=True)
def _fetch_registry() -> Registry | None:
"""Fetch the registry from the Hub. Returns `None` when offline or on any error."""
if constants.HF_HUB_OFFLINE:
return None
try:
from ._http import get_session
response = get_session().get(
f"{constants.ENDPOINT}/api/agent-harnesses",
timeout=_REGISTRY_FETCH_TIMEOUT,
)
response.raise_for_status()
return response.json()
except Exception:
logger.debug("Could not fetch agent harnesses registry from the Hub.", exc_info=True)
return None

View file

@ -1,9 +1,8 @@
# AI-generated module (ChatGPT)
import re
from typing import Optional
def load_dotenv(dotenv_str: str, environ: Optional[dict[str, str]] = None) -> dict[str, str]:
def load_dotenv(dotenv_str: str, environ: dict[str, str] | None = None) -> dict[str, str]:
"""
Parse a DOTENV-format string and return a dictionary of key-value pairs.
Handles quoted values, comments, export keyword, and blank lines.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2023-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -15,8 +14,8 @@
"""Contains utilities to flag a feature as "experimental" in Huggingface Hub."""
import warnings
from collections.abc import Callable
from functools import wraps
from typing import Callable
from .. import constants

View file

@ -4,9 +4,9 @@ import shutil
import stat
import tempfile
import time
from collections.abc import Callable, Generator
from functools import partial
from pathlib import Path
from typing import Callable, Generator, Optional, Union
import yaml
from filelock import BaseFileLock, FileLock, SoftFileLock, Timeout
@ -32,9 +32,9 @@ yaml_dump: Callable[..., str] = partial(yaml.dump, stream=None, allow_unicode=Tr
@contextlib.contextmanager
def SoftTemporaryDirectory(
suffix: Optional[str] = None,
prefix: Optional[str] = None,
dir: Optional[Union[Path, str]] = None,
suffix: str | None = None,
prefix: str | None = None,
dir: Path | str | None = None,
**kwargs,
) -> Generator[Path, None, None]:
"""
@ -74,20 +74,20 @@ def _set_write_permission_and_retry(func, path, excinfo):
@contextlib.contextmanager
def WeakFileLock(
lock_file: Union[str, Path], *, timeout: Optional[float] = None
) -> Generator[BaseFileLock, None, None]:
def WeakFileLock(lock_file: str | Path, *, timeout: float | None = None) -> Generator[BaseFileLock, None, None]:
"""A filelock with some custom logic.
This filelock is weaker than the default filelock in that:
1. It won't raise an exception if release fails.
2. It will default to a SoftFileLock if the filesystem does not support flock.
3. Lock files are created with mode 0o664 (group-writable) instead of the default 0o644.
This allows multiple users sharing a cache directory to wait for locks.
An INFO log message is emitted every 10 seconds if the lock is not acquired immediately.
If a timeout is provided, a `filelock.Timeout` exception is raised if the lock is not acquired within the timeout.
"""
log_interval = constants.FILELOCK_LOG_EVERY_SECONDS
lock = FileLock(lock_file, timeout=log_interval)
lock = FileLock(lock_file, timeout=log_interval, mode=0o664)
start_time = time.time()
while True:

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -16,7 +15,6 @@
import re
import subprocess
from typing import Optional
from ..constants import ENDPOINT
from ._subprocess import run_interactive_subprocess, run_subprocess
@ -34,7 +32,7 @@ GIT_CREDENTIAL_REGEX = re.compile(
)
def list_credential_helpers(folder: Optional[str] = None) -> list[str]:
def list_credential_helpers(folder: str | None = None) -> list[str]:
"""Return the list of git credential helpers configured.
See https://git-scm.com/docs/gitcredentials.
@ -51,10 +49,10 @@ def list_credential_helpers(folder: Optional[str] = None) -> list[str]:
parsed = _parse_credential_output(output)
return parsed
except subprocess.CalledProcessError as exc:
raise EnvironmentError(exc.stderr)
raise OSError(exc.stderr)
def set_git_credential(token: str, username: str = "hf_user", folder: Optional[str] = None) -> None:
def set_git_credential(token: str, username: str = "hf_user", folder: str | None = None) -> None:
"""Save a username/token pair in git credential for HF Hub registry.
Credentials are saved in all configured helpers (store, cache, macOS keychain,...).
@ -77,7 +75,7 @@ def set_git_credential(token: str, username: str = "hf_user", folder: Optional[s
stdin.flush()
def unset_git_credential(username: str = "hf_user", folder: Optional[str] = None) -> None:
def unset_git_credential(username: str = "hf_user", folder: str | None = None) -> None:
"""Erase credentials from git credential for HF Hub registry.
Credentials are erased from the configured helpers (store, cache, macOS
@ -115,7 +113,7 @@ def _parse_credential_output(output: str) -> list[str]:
# Example: `credential.https://huggingface.co.helper=store`
# See: https://github.com/huggingface/huggingface_hub/pull/1138#discussion_r1013324508
return sorted( # Sort for nice printing
set( # Might have some duplicates
{ # Might have some duplicates
match[0] for match in GIT_CREDENTIAL_REGEX.findall(output)
)
}
)

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -14,12 +13,11 @@
# limitations under the License.
"""Contains utilities to handle headers to send in calls to Huggingface Hub."""
from typing import Optional, Union
from huggingface_hub.errors import LocalTokenNotFoundError
from .. import constants
from ._auth import get_token
from ._detect_agent import detect_agent
from ._runtime import (
get_hf_hub_version,
get_python_version,
@ -32,11 +30,11 @@ from ._validators import validate_hf_hub_args
@validate_hf_hub_args
def build_hf_headers(
*,
token: Optional[Union[bool, str]] = None,
library_name: Optional[str] = None,
library_version: Optional[str] = None,
user_agent: Union[dict, str, None] = None,
headers: Optional[dict[str, str]] = None,
token: bool | str | None = None,
library_name: str | None = None,
library_version: str | None = None,
user_agent: dict | str | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, str]:
"""
Build headers dictionary to send in a HF Hub call.
@ -124,7 +122,7 @@ def build_hf_headers(
return hf_headers
def get_token_to_send(token: Optional[Union[bool, str]]) -> Optional[str]:
def get_token_to_send(token: bool | str | None) -> str | None:
"""Select the token to send from either `token` or the cache."""
# Case token is explicitly provided
if isinstance(token, str):
@ -158,9 +156,9 @@ def get_token_to_send(token: Optional[Union[bool, str]]) -> Optional[str]:
def _http_user_agent(
*,
library_name: Optional[str] = None,
library_version: Optional[str] = None,
user_agent: Union[dict, str, None] = None,
library_name: str | None = None,
library_version: str | None = None,
user_agent: dict | str | None = None,
) -> str:
"""Format a user-agent string containing information about the installed packages.
@ -186,6 +184,10 @@ def _http_user_agent(
if is_torch_available():
ua += f"; torch/{get_torch_version()}"
agent = detect_agent()
if agent:
ua += f"; agent/{agent}"
if isinstance(user_agent, dict):
ua += "; " + "; ".join(f"{k}/{v}" for k, v in user_agent.items())
elif isinstance(user_agent, str):

View file

@ -0,0 +1,607 @@
# Copyright 2026-present, the HuggingFace Inc. team.
#
# 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.
"""Centralized parser for Hugging Face Hub URIs ('hf://...') and mount specifications.
A HF URI is a URI-like string that identifies a location on the Hugging Face
Hub: a model/dataset/space/kernel repository, a bucket, optionally a revision,
and optionally a path inside the repo or bucket.
Canonical syntax:
```
hf://[<TYPE>/]<ID>[@<REVISION>][/<PATH>]
```
For convenience, [`parse_hf_uri`] also accepts Hugging Face **web URLs** (the
ones you copy-paste from your browser), e.g.
'https://huggingface.co/datasets/my-org/my-dataset/blob/main/train.csv'. They are
normalized to the canonical 'hf://' form before parsing. Only unambiguous URLs
(repository / bucket pages and file/folder viewer routes) are accepted; any other
route is rejected rather than guessed.
A HF mount wraps a HF URI with a local mount path and an optional ':ro'/':rw'
flag (used by Spaces and Jobs volumes):
```
hf://[<TYPE>/]<ID>[@<REVISION>][/<PATH>]:<MOUNT_PATH>[:ro|:rw]
```
See 'docs/source/en/package_reference/hf_uris.md' for the full grammar and examples.
"""
import functools
import re
from dataclasses import dataclass, field
from urllib.parse import quote, unquote, urlsplit
from huggingface_hub import constants
from huggingface_hub.errors import HfUriError, HFValidationError
from ._validators import validate_repo_id
# Inverse map (singular -> plural URI prefix). Built once from the canonical
# 'constants.HF_URI_TYPE_PREFIXES' and used to render URIs.
_TYPE_TO_PREFIX: dict[str, str] = {v: k for k, v in constants.HF_URI_TYPE_PREFIXES.items()}
# Special revisions that contain a '/'. They take precedence when splitting
# the part after '@' into '<revision>/<path-in-repo>'. Matches 'refs/pr/N'
# (Pull Request refs) and 'refs/convert/<name>' (e.g. parquet conversions).
# The conversion name allows the typical git ref characters '[a-zA-Z0-9_.-]'
# so names like 'parquet-v2' or 'duckdb.v1' round-trip correctly.
_SPECIAL_REFS_REVISION_REGEX = re.compile(r"^refs/(?:convert/[\w.-]+|pr/\d+)")
# Same as constants.HfUriType, but as a set of strings for easy lookup.)
_VALID_URI_TYPES: frozenset[str] = frozenset(constants.HF_URI_TYPE_PREFIXES.values())
# Web-viewer routes that point at a file or folder and that map cleanly onto a
# '<revision>/<path>' pair. Other routes (commit, commits, discussions, settings,
# edit, ...) do not identify a Hub location and are rejected by the URL parser.
_URL_REPO_LOCATION_ACTIONS: frozenset[str] = frozenset({"blame", "blob", "raw", "resolve", "tree"})
# Bucket web routes that point at a file or folder. Buckets are not versioned, so
# these are followed directly by '<path>' (no revision segment).
_URL_BUCKET_LOCATION_ACTIONS: frozenset[str] = frozenset({"resolve", "tree"})
@dataclass(frozen=True)
class HfUri:
"""Parsed representation of a Hugging Face Hub URI ('hf://...').
Attributes:
type (`str`):
One of 'model', 'dataset', 'space', 'kernel' or 'bucket'.
id (`str`):
The repository id ('namespace/name', e.g. 'my-org/my-model') for repo URIs, or the bucket id ('namespace/name') for bucket URIs.
revision (`str`, *optional*):
The revision specified after '@' in the URI, URL-decoded. 'None' if no revision was specified, or for bucket URIs (which
never carry a revision). Special refs like 'refs/pr/10' and 'refs/convert/parquet' are preserved as-is.
path_in_repo (`str`):
The path inside the repo or bucket. Empty string if the URI points at the root.
"""
type: constants.HfUriType
id: str
revision: str | None = None
path_in_repo: str = ""
_raw: str | None = field(repr=False, hash=False, compare=False, default=None)
def __post_init__(self) -> None:
uri = self._raw or "" # For error messages
# Check valid URI type
if self.type not in _VALID_URI_TYPES:
raise HfUriError(uri=uri, msg=f"Invalid type '{self.type}'. Must be one of {sorted(_VALID_URI_TYPES)}.")
# Check valid ID
if not self.id or self.id.count("/") != 1:
raise HfUriError(uri=uri, msg=f"Id must be 'namespace/name', got '{self.id}'.")
if self.type != "bucket":
try:
validate_repo_id(self.id)
except HFValidationError as e:
raise HfUriError(uri=uri, msg=str(e)) from e
# Check valid revision
if self.revision is not None and not self.revision:
raise HfUriError(uri=uri, msg="Revision must not be an empty string.")
if self.type == "bucket" and self.revision is not None:
raise HfUriError(uri=uri, msg="Bucket URIs do not support a revision.")
# Check valid path in repo
if self.path_in_repo:
if self.path_in_repo.startswith("/") or "//" in self.path_in_repo:
raise HfUriError(uri=uri, msg=f"Path must not contain empty segments (got '{self.path_in_repo}').")
@property
def is_bucket(self) -> bool:
"""True if this URI points at a bucket."""
return self.type == "bucket"
@property
def is_repo(self) -> bool:
"""True if this URI points at a repository (model, dataset, space or kernel)."""
return self.type != "bucket"
def to_uri(self) -> str:
"""Render the URI as a canonical 'hf://' string.
The type prefix is always written explicitly (e.g. 'hf://models/my-org/my-model').
"""
parts: list[str] = [constants.HF_PROTOCOL, _TYPE_TO_PREFIX[self.type], "/", self.id]
if self.revision is not None:
# Encode '/' as '%2F' for revisions that would otherwise be split as '<revision>/<path>'
# at parse time. Special refs ('refs/pr/N', 'refs/convert/<name>') are kept verbatim
# because the parser matches them eagerly.
revision = self.revision
if "/" in revision and _SPECIAL_REFS_REVISION_REGEX.fullmatch(revision) is None:
revision = revision.replace("/", "%2F")
parts.append(f"@{revision}")
if self.path_in_repo:
parts.append(f"/{self.path_in_repo}")
return "".join(parts)
def to_url(self, endpoint: str | None = None) -> str:
"""Render the URI as a Hugging Face **web URL** (the kind you open in a browser).
This is the inverse of parsing a URL with [`parse_hf_uri`]. The returned URL points at:
- the repository / bucket landing page when no path or revision is set;
- the folder viewer ('/tree/<revision>') when only a revision is set;
- the file viewer ('/blob/<revision>/<path>') for repository files (revision defaults to 'main');
- the tree route ('/tree/<path>') for bucket files (buckets are not versioned).
Args:
endpoint (`str`, *optional*):
Base endpoint to use. Defaults to 'constants.ENDPOINT' (i.e. 'https://huggingface.co').
Returns:
`str`: the web URL.
Example:
```py
>>> from huggingface_hub import parse_hf_uri
>>> parse_hf_uri("hf://datasets/my-org/my-dataset@v1/train.csv").to_url()
'https://huggingface.co/datasets/my-org/my-dataset/blob/v1/train.csv'
```
"""
base = (endpoint or constants.ENDPOINT).rstrip("/")
# Percent-encode characters that would otherwise break the URL (spaces, '#', '?', ...),
# keeping '/' as the path separator. This is the inverse of the decoding done when parsing.
path = quote(self.path_in_repo, safe="/")
if self.type == "bucket":
url = f"{base}/buckets/{self.id}"
if path:
url += f"/tree/{path}"
return url
# Models live at the root ('hf.co/<id>'); other repos are namespaced by their plural prefix.
url = f"{base}/{self.id}" if self.type == "model" else f"{base}/{_TYPE_TO_PREFIX[self.type]}/{self.id}"
revision = self.revision
# Encode '/' in branch/tag names so the revision stays a single URL segment. Special refs
# ('refs/pr/N', 'refs/convert/<name>') are used verbatim by the Hub web routes.
if revision is not None and "/" in revision and _SPECIAL_REFS_REVISION_REGEX.fullmatch(revision) is None:
revision = revision.replace("/", "%2F")
if path:
url += f"/blob/{revision or constants.DEFAULT_REVISION}/{path}"
elif revision is not None:
url += f"/tree/{revision}"
return url
@dataclass(frozen=True)
class HfMount:
"""A HF URI paired with a local mount path and optional read-only flag.
Used by Spaces and Jobs to describe volume mounts. The full syntax is:
```
hf://[<TYPE>/]<ID>[@<REVISION>][/<PATH>]:<MOUNT_PATH>[:ro|:rw]
```
Attributes:
source ([`HfUri`]):
The parsed HF URI identifying the Hub resource to mount.
mount_path (`str`):
The local mount path (always starts with '/').
read_only (`bool`, *optional*):
True if the mount ends with ':ro', False if it ends with ':rw', 'None' if no flag was provided.
"""
source: HfUri
mount_path: str
read_only: bool | None = None
_raw: str | None = field(repr=False, hash=False, compare=False, default=None)
def __post_init__(self) -> None:
raw = self._raw or ""
if not self.mount_path.startswith("/") or self.mount_path == "/":
raise HfUriError(
uri=raw,
msg=f"Mount path must be a non-empty absolute path starting with '/', got '{self.mount_path}'.",
)
def to_uri(self) -> str:
"""Render the mount as a canonical 'hf://' string.
Example: 'hf://models/my-org/my-model:/data:ro'
"""
parts = [self.source.to_uri(), ":", self.mount_path]
if self.read_only is not None:
parts.append(":ro" if self.read_only else ":rw")
return "".join(parts)
def is_hf_uri(uri: str) -> bool:
"""Check if a string is a valid HF URI ('hf://...') or a recognized Hugging Face web URL."""
try:
parse_hf_uri(uri)
return True
except HfUriError:
return False
@functools.lru_cache
def parse_hf_uri(uri: str) -> HfUri:
"""Parse a Hugging Face Hub URI ('hf://...') or a Hugging Face web URL.
A HF URI is a URI-like string identifying a location on the Hugging Face Hub. The full grammar is:
```
hf://[<TYPE>/]<ID>[@<REVISION>][/<PATH>]
```
For convenience, Hugging Face **web URLs** (the ones you copy-paste from the website) are also
accepted and normalized to the canonical 'hf://' form, e.g.
'https://huggingface.co/datasets/my-org/my-dataset/blob/main/train.csv'. Only unambiguous URLs
(repository / bucket pages and file/folder viewer routes) are accepted; any other route is rejected.
See 'docs/source/en/package_reference/hf_uris.md' for the full specification.
Args:
uri (`str`):
The URI to parse. Must start with 'hf://', or be a Hugging Face URL (e.g. 'https://huggingface.co/...').
Returns:
[`HfUri`]: the parsed URI.
Raises:
[`HfUriError`]:
If the URI is malformed (missing prefix, invalid type, missing id, unsupported URL route, etc.).
Examples:
```py
>>> from huggingface_hub.utils import parse_hf_uri
>>> parse_hf_uri("hf://my-org/my-model")
HfUri(type='model', id='my-org/my-model', revision=None, path_in_repo='')
>>> parse_hf_uri("hf://datasets/my-org/my-dataset@refs/pr/3/train.json")
HfUri(type='dataset', id='my-org/my-dataset', revision='refs/pr/3', path_in_repo='train.json')
>>> parse_hf_uri("https://huggingface.co/datasets/my-org/my-dataset/blob/main/train.csv")
HfUri(type='dataset', id='my-org/my-dataset', revision='main', path_in_repo='train.csv')
```
"""
raw = uri
if uri.startswith(constants.HF_PROTOCOL):
body = uri[len(constants.HF_PROTOCOL) :]
if not body:
raise HfUriError(uri, f"Empty body after '{constants.HF_PROTOCOL}'.")
elif _looks_like_hf_url(uri):
body = _url_to_uri_body(uri)
else:
raise HfUriError(
uri,
f"Must start with '{constants.HF_PROTOCOL}' or be a Hugging Face URL (e.g. 'https://huggingface.co/...'). "
f"Expected format: {constants.HF_PROTOCOL}[<TYPE>/]<ID>[@<REVISION>][/<PATH>]",
)
type_, location = _split_type(body, raw=raw)
if type_ == "bucket":
return _parse_bucket_body(location, type_, raw=raw)
return _parse_repo_body(location, type_, raw=raw)
def _looks_like_hf_url(uri: str) -> bool:
"""Return True if 'uri' looks like a (possibly scheme-less) Hugging Face web URL."""
lowered = uri.lower()
if lowered.startswith(("http://", "https://")):
return True
# Scheme-less host (e.g. 'huggingface.co/org/model').
return any(lowered == host or lowered.startswith(host + "/") for host in constants.HF_URL_HOSTS)
def _decode_url_path_segment(segment: str) -> str:
"""Percent-decode a single URL path segment (e.g. 'file%20name.txt' -> 'file name.txt').
A decoded '/' is re-encoded as '%2F' so the segment stays atomic when the normalized body is
re-split by the shared parser. This decodes ordinary path characters (spaces, '#', ...) that
browsers encode, while keeping '%2F'-encoded revisions (e.g. 'feature%2Ffoo') intact.
"""
return unquote(segment).replace("/", "%2F")
def _url_to_uri_body(url: str) -> str:
"""Normalize a Hugging Face web URL into the body of a 'hf://' URI (everything after 'hf://').
The returned string is fed back into the regular URI parsing logic, so all validation
(repo id, revision, empty path segments, ...) is shared with the canonical 'hf://' path.
Only unambiguous URLs are accepted: any unrecognized route raises [`HfUriError`].
"""
raw = url
# Prefix '//' for scheme-less inputs so 'urlsplit' populates 'netloc' instead of 'path'.
parsed = urlsplit(url if "://" in url else "//" + url)
host = (parsed.hostname or "").lower()
if host not in constants.HF_URL_HOSTS:
raise HfUriError(
uri=raw,
msg=f"Unrecognized host '{host or url}'. Expected a Hugging Face URL (e.g. 'https://huggingface.co/...').",
)
# Query string and fragment are intentionally dropped (e.g. '?download=true').
segments = [segment for segment in parsed.path.split("/") if segment]
if not segments:
raise HfUriError(uri=raw, msg=f"Missing repository or bucket identifier in URL '{url}'.")
# Optional type prefix ('datasets', 'spaces', 'kernels', 'buckets', 'models').
type_prefix: str | None = None
if segments[0] in constants.HF_URI_TYPE_PREFIXES:
type_prefix = segments[0]
segments = segments[1:]
# Everything in the web UI is namespaced ('<namespace>/<name>'); a single segment is a user or
# organization page (or a listing page), which we cannot map to a repository -> reject.
if len(segments) < 2:
raise HfUriError(
uri=raw,
msg=(
f"Cannot parse URL '{url}': expected a '<namespace>/<name>' repository or bucket. "
"User/organization pages and single-segment URLs are not supported."
),
)
repo_id = f"{segments[0]}/{segments[1]}"
rest = segments[2:]
if type_prefix == "buckets":
if not rest:
return f"buckets/{repo_id}"
action, *tail = rest
if action not in _URL_BUCKET_LOCATION_ACTIONS:
raise HfUriError(uri=raw, msg=f"Cannot parse bucket URL '{url}': unsupported '/{action}/' route.")
path = "/".join(_decode_url_path_segment(segment) for segment in tail)
return f"buckets/{repo_id}/{path}" if path else f"buckets/{repo_id}"
prefix = f"{type_prefix}/" if type_prefix else ""
if not rest:
return f"{prefix}{repo_id}"
action, *tail = rest
if action not in _URL_REPO_LOCATION_ACTIONS:
raise HfUriError(
uri=raw,
msg=(
f"Cannot parse URL '{url}': unsupported '/{action}/' route. "
"Only repository pages and file/folder viewer routes (blob, resolve, raw, tree, ...) can be parsed."
),
)
if not tail:
# e.g. '.../tree' with nothing after -> repository root.
return f"{prefix}{repo_id}"
# 'tail' is '<revision>/<path>'; reuse the canonical '@<revision>/<path>' splitting logic
# (special refs, URL-encoded slashes, ...) by handing it back to the URI parser. Each segment
# is percent-decoded first so file names with spaces, '#', ... resolve correctly; the revision
# segment's '%2F' survives (re-encoded by '_decode_url_path_segment') and is decoded downstream.
decoded = "/".join(_decode_url_path_segment(segment) for segment in tail)
return f"{prefix}{repo_id}@{decoded}"
def parse_hf_mount(mount_str: str) -> HfMount:
"""Parse a HF mount specification ('hf://...:<MOUNT_PATH>[:ro|:rw]').
A mount specification is a HF URI followed by a local mount path and an optional read-only/read-write flag.
The full grammar is:
```
hf://[<TYPE>/]<ID>[@<REVISION>][/<PATH>]:<MOUNT_PATH>[:ro|:rw]
```
See 'docs/source/en/package_reference/hf_uris.md' for the full specification.
Args:
mount_str (`str`):
The mount string to parse. Must start with 'hf://' and contain a ':<MOUNT_PATH>' segment.
Returns:
[`HfMount`]: the parsed mount.
Raises:
[`HfUriError`]:
If the mount string is malformed (missing mount path, invalid URI, etc.).
Examples:
```py
>>> from huggingface_hub.utils import parse_hf_mount
>>> parse_hf_mount("hf://my-org/my-model:/data:ro")
HfMount(source=HfUri(type='model', id='my-org/my-model', revision=None, path_in_repo=''), mount_path='/data', read_only=True)
>>> parse_hf_mount("hf://buckets/my-org/my-bucket/sub/dir:/mnt:rw")
HfMount(source=HfUri(type='bucket', id='my-org/my-bucket', revision=None, path_in_repo='sub/dir'), mount_path='/mnt', read_only=False)
```
"""
if not mount_str.startswith(constants.HF_PROTOCOL):
raise HfUriError(
uri=mount_str,
msg=f"Must start with '{constants.HF_PROTOCOL}'.",
)
raw = mount_str
body = mount_str[len(constants.HF_PROTOCOL) :]
if not body:
raise HfUriError(uri=raw, msg=f"Empty body after '{constants.HF_PROTOCOL}'.")
location, mount_path, read_only = _split_mount(body, raw=raw)
if mount_path is None:
raise HfUriError(uri=raw, msg="Missing mount path. Expected ':<MOUNT_PATH>' (e.g. 'hf://org/model:/data').")
# Re-assemble the URI part and parse it
uri_str = constants.HF_PROTOCOL + location
try:
source = parse_hf_uri(uri_str)
except HfUriError as e:
raise HfUriError(uri=raw, msg=e.msg) from e
return HfMount(source=source, mount_path=mount_path, read_only=read_only, _raw=raw)
def _split_mount(body: str, *, raw: str) -> tuple[str, str | None, bool | None]:
"""Split the ':<MOUNT_PATH>[:ro|:rw]' suffix from 'body'.
Returns '(location, mount_path, read_only)' where 'mount_path' is 'None' if no mount segment is present.
"""
if body.endswith(":ro"):
read_only, body = True, body.removesuffix(":ro")
elif body.endswith(":rw"):
read_only, body = False, body.removesuffix(":rw")
else:
read_only = None
# Mount paths always start with '/', so the delimiter is ':/'.
# We use rfind() because the mount segment is always trailing
idx = body.rfind(":/")
if idx == -1:
if read_only is not None:
raise HfUriError(
uri=raw,
msg="':ro'/':rw' suffix is only valid when a mount path is provided (e.g. 'hf://...:/<MOUNT_PATH>:ro').",
)
return body, None, None
location = body[:idx]
mount_path = body[idx + 1 :] # includes the leading '/'
if not location:
raise HfUriError(uri=raw, msg="Missing location before mount path.")
return location, mount_path, read_only
def _split_type(location: str, *, raw: str) -> tuple[constants.HfUriType, str]:
"""Detect the (optional) type prefix and return '(type, remaining_location)'.
A missing type prefix defaults to 'model'. Singular forms ('model/', 'dataset/', etc.) are explicitly rejected with a helpful error.
"""
slash_idx = location.find("/")
if slash_idx == -1:
# Single segment, no prefix. Reject if it looks like a bare type name.
if location in constants.HF_URI_TYPE_PREFIXES:
raise HfUriError(
uri=raw,
msg=f"Missing identifier after '{location}'. Expected '{constants.HF_PROTOCOL}{location}/<ID>'.",
)
if (singular_plural := _TYPE_TO_PREFIX.get(location)) is not None:
raise HfUriError(
uri=raw,
msg=f"Type prefix must be plural. Did you mean '{constants.HF_PROTOCOL}{singular_plural}/...'?",
)
return "model", location
first = location[:slash_idx]
rest = location[slash_idx + 1 :]
if first in constants.HF_URI_TYPE_PREFIXES:
return constants.HF_URI_TYPE_PREFIXES[first], rest
if (singular_plural := _TYPE_TO_PREFIX.get(first)) is not None:
raise HfUriError(
uri=raw, msg=f"Type prefix must be plural, got '{first}/'. Did you mean '{singular_plural}/'?"
)
return "model", location
def _parse_bucket_body(
location: str,
type_: constants.HfUriType,
*,
raw: str,
) -> HfUri:
"""Parse the body of a bucket URI: 'namespace/name[/path]'."""
location = location.strip("/")
parts = location.split("/", 2)
if len(parts) < 2 or not parts[0] or not parts[1]:
raise HfUriError(uri=raw, msg=f"Bucket id must be 'namespace/name', got '{location}'.")
bucket_id = f"{parts[0]}/{parts[1]}"
if "@" in bucket_id:
raise HfUriError(uri=raw, msg="Bucket URIs do not support a revision marker ('@').")
path_in_bucket = parts[2] if len(parts) >= 3 else ""
return HfUri(
type=type_,
id=bucket_id,
revision=None,
path_in_repo=path_in_bucket,
_raw=raw,
)
def _parse_repo_body(
location: str,
type_: constants.HfUriType,
*,
raw: str,
) -> HfUri:
"""Parse the body of a repo URI: '<repo_id>[@<revision>][/<path>]'."""
location = location.strip("/")
if not location:
raise HfUriError(uri=raw, msg="Missing repository id.")
# The '@' separates the repo_id from the revision, but only when it
# appears right after 'namespace/name' (at most one '/' before it).
# An '@' deeper in the path (e.g. in a filename like 'file@1.txt') is literal.
at_idx = location.find("@")
revision: str | None
if at_idx == -1 or location[:at_idx].count("/") > 1:
# No '@' at all, or the '@' is past the repo_id portion (in a filename).
revision = None
parts = location.split("/", 2)
if len(parts) < 2:
raise HfUriError(uri=raw, msg=f"Repository id must be 'namespace/name', got '{location}'. ")
repo_id = f"{parts[0]}/{parts[1]}"
path_in_repo = parts[2] if len(parts) > 2 else ""
else:
repo_id = location[:at_idx]
rev_and_path = location[at_idx + 1 :]
if not repo_id:
raise HfUriError(uri=raw, msg="Missing repository id before '@'.")
if repo_id.count("/") != 1:
raise HfUriError(uri=raw, msg=f"Repository id must be 'namespace/name', got '{repo_id}'.")
# Special refs like 'refs/pr/10' contain '/' and must be matched eagerly,
# otherwise we would split them at the first '/' and treat the rest as a path.
match = _SPECIAL_REFS_REVISION_REGEX.match(rev_and_path)
if match is not None:
revision = match.group()
path_in_repo = rev_and_path[len(revision) :].removeprefix("/")
else:
slash_idx = rev_and_path.find("/")
if slash_idx == -1:
revision = rev_and_path
path_in_repo = ""
else:
revision = rev_and_path[:slash_idx]
path_in_repo = rev_and_path[slash_idx + 1 :]
revision = unquote(revision)
if not revision:
raise HfUriError(uri=raw, msg="Empty revision after '@'.")
return HfUri(
type=type_,
id=repo_id,
revision=revision,
path_in_repo=path_in_repo,
_raw=raw,
)

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -22,10 +21,12 @@ import re
import threading
import time
import uuid
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from shlex import quote
from typing import Any, Callable, Generator, Mapping, Optional, Union
from typing import Any, TypeVar
from urllib.parse import urlparse
import httpx
@ -34,6 +35,7 @@ from huggingface_hub.errors import OfflineModeIsEnabled
from .. import constants
from ..errors import (
BadRequestError,
BucketNotFoundError,
DisabledRepoError,
GatedRepoError,
HfHubHTTPError,
@ -66,8 +68,8 @@ class RateLimitInfo:
resource_type: str
remaining: int
reset_in_seconds: int
limit: Optional[int] = None
window_seconds: Optional[int] = None
limit: int | None = None
window_seconds: int | None = None
# Regex patterns for parsing rate limit headers
@ -77,7 +79,7 @@ _RATELIMIT_REGEX = re.compile(r"\"(?P<resource_type>\w+)\"\s*;\s*r\s*=\s*(?P<r>\
_RATELIMIT_POLICY_REGEX = re.compile(r"q\s*=\s*(?P<q>\d+).*?w\s*=\s*(?P<w>\d+)")
def parse_ratelimit_headers(headers: Mapping[str, str]) -> Optional[RateLimitInfo]:
def parse_ratelimit_headers(headers: Mapping[str, str]) -> RateLimitInfo | None:
"""Parse rate limit information from HTTP response headers.
Follows IETF draft: https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-09.html
@ -98,8 +100,8 @@ def parse_ratelimit_headers(headers: Mapping[str, str]) -> Optional[RateLimitInf
```
"""
ratelimit: Optional[str] = None
policy: Optional[str] = None
ratelimit: str | None = None
policy: str | None = None
for key in headers:
lower_key = key.lower()
if lower_key == "ratelimit":
@ -118,8 +120,8 @@ def parse_ratelimit_headers(headers: Mapping[str, str]) -> Optional[RateLimitInf
remaining = int(match.group("r"))
reset_in_seconds = int(match.group("t"))
limit: Optional[int] = None
window_seconds: Optional[int] = None
limit: int | None = None
window_seconds: int | None = None
if policy:
policy_match = _RATELIMIT_POLICY_REGEX.search(policy)
@ -136,11 +138,11 @@ def parse_ratelimit_headers(headers: Mapping[str, str]) -> Optional[RateLimitInf
)
# Both headers are used by the Hub to debug failed requests.
# `X_AMZN_TRACE_ID` is better as it also works to debug on Cloudfront and ALB.
# If `X_AMZN_TRACE_ID` is set, the Hub will use it as well.
X_AMZN_TRACE_ID = "X-Amzn-Trace-Id"
# When raising an error, we include the request id in the error message for easier debugging.
# Request ID is sourced from headers in order of precedence: "X-Request-Id", "X-Amzn-Trace-Id", "X-Amz-Cf-Id".
X_REQUEST_ID = "x-request-id"
X_AMZN_TRACE_ID = "X-Amzn-Trace-Id"
X_AMZ_CF_ID = "x-amz-cf-id"
REPO_API_REGEX = re.compile(
r"""
@ -157,6 +159,57 @@ REPO_API_REGEX = re.compile(
flags=re.VERBOSE,
)
BUCKET_API_REGEX = re.compile(
r"""
# staging or production endpoint
^https?://[^/]+
# on /api/buckets/...
/api/buckets/
""",
flags=re.VERBOSE,
)
# Regex to extract repo_type and repo_id from API URLs.
# Captures: group(1) = repo_type plural (models/datasets/spaces), group(2) = first path segment, group(3) = optional second segment.
_REPO_ID_FROM_URL_REGEX = re.compile(r"^https?://[^/]+/api/(models|datasets|spaces)/([^/]+)(?:/([^/]+))?")
# Regex to extract bucket_id (namespace/name) from bucket API URLs.
_BUCKET_ID_FROM_URL_REGEX = re.compile(r"^https?://[^/]+/api/buckets/([^/]+/[^/]+)")
# Sub-paths that follow a repo_id in API URLs (not part of the repo name).
_REPO_URL_SUBPATHS = {"resolve", "tree", "blob", "raw", "refs", "commit", "discussions", "settings", "revision"}
def _parse_repo_info_from_url(url: str) -> tuple[str | None, str | None]:
"""Extract (repo_type, repo_id) from an API URL.
Returns canonical repo_type values: "model", "dataset", "space" (or None).
Examples:
>>> _parse_repo_info_from_url("https://huggingface.co/api/models/user/repo")
("model", "user/repo")
>>> _parse_repo_info_from_url("https://huggingface.co/api/datasets/user/repo/resolve/main/data.csv")
("dataset", "user/repo")
>>> _parse_repo_info_from_url("https://huggingface.co/api/models/bert-base-cased/resolve/main/config.json")
("model", "bert-base-cased")
"""
match = _REPO_ID_FROM_URL_REGEX.search(url)
if not match:
return None, None
repo_type = constants.REPO_TYPES_MAPPING.get(match.group(1))
first, second = match.group(2), match.group(3)
if second and second not in _REPO_URL_SUBPATHS:
repo_id = f"{first}/{second}"
else:
repo_id = first
return repo_type, repo_id
def _parse_bucket_id_from_url(url: str) -> str | None:
"""Extract bucket_id (namespace/name) from a bucket API URL."""
match = _BUCKET_ID_FROM_URL_REGEX.search(url)
return match.group(1) if match else None
def hf_request_event_hook(request: httpx.Request) -> None:
"""
@ -219,7 +272,7 @@ def default_client_factory() -> httpx.Client:
return httpx.Client(
event_hooks={"request": [hf_request_event_hook]},
follow_redirects=True,
timeout=httpx.Timeout(constants.DEFAULT_REQUEST_TIMEOUT, write=60.0),
timeout=None,
)
@ -230,7 +283,7 @@ def default_async_client_factory() -> httpx.AsyncClient:
return httpx.AsyncClient(
event_hooks={"request": [async_hf_request_event_hook], "response": [async_hf_response_event_hook]},
follow_redirects=True,
timeout=httpx.Timeout(constants.DEFAULT_REQUEST_TIMEOUT, write=60.0),
timeout=None,
)
@ -240,19 +293,19 @@ ASYNC_CLIENT_FACTORY_T = Callable[[], httpx.AsyncClient]
_CLIENT_LOCK = threading.Lock()
_GLOBAL_CLIENT_FACTORY: CLIENT_FACTORY_T = default_client_factory
_GLOBAL_ASYNC_CLIENT_FACTORY: ASYNC_CLIENT_FACTORY_T = default_async_client_factory
_GLOBAL_CLIENT: Optional[httpx.Client] = None
_GLOBAL_CLIENT: httpx.Client | None = None
def set_client_factory(client_factory: CLIENT_FACTORY_T) -> None:
"""
Set the HTTP client factory to be used by `huggingface_hub`.
The client factory is a method that returns a `httpx.Client` object. On the first call to [`get_client`] the client factory
The client factory is a method that returns a `httpx.Client` object. On the first call to [`get_session`] the client factory
will be used to create a new `httpx.Client` object that will be shared between all calls made by `huggingface_hub`.
This can be useful if you are running your scripts in a specific environment requiring custom configuration (e.g. custom proxy or certifications).
Use [`get_client`] to get a correctly configured `httpx.Client`.
Use [`get_session`] to get a correctly configured `httpx.Client`.
"""
global _GLOBAL_CLIENT_FACTORY
with _CLIENT_LOCK:
@ -348,8 +401,8 @@ def _http_backoff_base(
max_retries: int = 5,
base_wait_time: float = 1,
max_wait_time: float = 8,
retry_on_exceptions: Union[type[Exception], tuple[type[Exception], ...]] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: Union[int, tuple[int, ...]] = _DEFAULT_RETRY_ON_STATUS_CODES,
retry_on_exceptions: type[Exception] | tuple[type[Exception], ...] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: int | tuple[int, ...] = _DEFAULT_RETRY_ON_STATUS_CODES,
stream: bool = False,
**kwargs,
) -> Generator[httpx.Response, None, None]:
@ -362,7 +415,7 @@ def _http_backoff_base(
nb_tries = 0
sleep_time = base_wait_time
ratelimit_reset: Optional[int] = None # seconds to wait for rate limit reset if 429 response
ratelimit_reset: int | None = None # seconds to wait for rate limit reset if 429 response
# If `data` is used and is a file object (or any IO), it will be consumed on the
# first HTTP request. We need to save the initial position so that the full content
@ -445,8 +498,8 @@ def http_backoff(
max_retries: int = 5,
base_wait_time: float = 1,
max_wait_time: float = 8,
retry_on_exceptions: Union[type[Exception], tuple[type[Exception], ...]] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: Union[int, tuple[int, ...]] = _DEFAULT_RETRY_ON_STATUS_CODES,
retry_on_exceptions: type[Exception] | tuple[type[Exception], ...] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: int | tuple[int, ...] = _DEFAULT_RETRY_ON_STATUS_CODES,
**kwargs,
) -> httpx.Response:
"""Wrapper around httpx to retry calls on an endpoint, with exponential backoff.
@ -526,8 +579,8 @@ def http_stream_backoff(
max_retries: int = 5,
base_wait_time: float = 1,
max_wait_time: float = 8,
retry_on_exceptions: Union[type[Exception], tuple[type[Exception], ...]] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: Union[int, tuple[int, ...]] = _DEFAULT_RETRY_ON_STATUS_CODES,
retry_on_exceptions: type[Exception] | tuple[type[Exception], ...] = _DEFAULT_RETRY_ON_EXCEPTIONS,
retry_on_status_codes: int | tuple[int, ...] = _DEFAULT_RETRY_ON_STATUS_CODES,
**kwargs,
) -> Generator[httpx.Response, None, None]:
"""Wrapper around httpx to retry calls on an endpoint, with exponential backoff.
@ -601,7 +654,58 @@ def http_stream_backoff(
)
def fix_hf_endpoint_in_url(url: str, endpoint: Optional[str]) -> str:
def _httpx_follow_relative_redirects_with_backoff(
method: HTTP_METHOD_T, url: str, *, retry_on_errors: bool = False, **httpx_kwargs
) -> httpx.Response:
"""Perform an HTTP request with backoff and follow relative redirects only.
Used to fetch HEAD /resolve on repo or bucket files.
This is useful to follow a redirection to a renamed repository without following redirection to a CDN.
A backoff mechanism retries the HTTP call on errors (429, 5xx, timeout, network errors).
Args:
method (`str`):
HTTP method, such as 'GET' or 'HEAD'.
url (`str`):
The URL of the resource to fetch.
retry_on_errors (`bool`, *optional*, defaults to `False`):
Whether to retry on errors. If False, no retry is performed (fast fallback to local cache).
If True, uses default retry behavior (429, 5xx, timeout, network errors).
**httpx_kwargs (`dict`, *optional*):
Params to pass to `httpx.request`.
"""
# if `retry_on_errors=False`, disable all retries for fast fallback to cache
no_retry_kwargs: dict[str, Any] = (
{} if retry_on_errors else {"retry_on_exceptions": (), "retry_on_status_codes": ()}
)
while True:
response = http_backoff(
method=method,
url=url,
**httpx_kwargs,
follow_redirects=False,
**no_retry_kwargs,
)
hf_raise_for_status(response)
# Check if response is a relative redirect
if 300 <= response.status_code <= 399:
parsed_target = urlparse(response.headers["Location"])
if parsed_target.netloc == "":
# Relative redirect -> update URL and retry
url = urlparse(url)._replace(path=parsed_target.path).geturl()
continue
# Break if no relative redirect
break
return response
def fix_hf_endpoint_in_url(url: str, endpoint: str | None) -> str:
"""Replace the default endpoint in a URL by a custom one.
This is useful when using a proxy and the Hugging Face Hub returns a URL with the default endpoint.
@ -614,7 +718,7 @@ def fix_hf_endpoint_in_url(url: str, endpoint: Optional[str]) -> str:
return url
def hf_raise_for_status(response: httpx.Response, endpoint_name: Optional[str] = None) -> None:
def hf_raise_for_status(response: httpx.Response, endpoint_name: str | None = None) -> None:
"""
Internal version of `response.raise_for_status()` that will refine a potential HTTPError.
Raised exception will be an instance of [`~errors.HfHubHTTPError`].
@ -662,19 +766,25 @@ def hf_raise_for_status(response: httpx.Response, endpoint_name: Optional[str] =
error_code = response.headers.get("X-Error-Code")
error_message = response.headers.get("X-Error-Message")
# Parse repo info from request URL (used to enrich errors below)
request_url = (
str(response.request.url) if response.request is not None and response.request.url is not None else None
)
repo_type, repo_id = _parse_repo_info_from_url(request_url) if request_url else (None, None)
if error_code == "RevisionNotFound":
message = f"{response.status_code} Client Error." + "\n\n" + f"Revision Not Found for url: {response.url}."
raise _format(RevisionNotFoundError, message, response) from e
raise _format(RevisionNotFoundError, message, response, repo_type=repo_type, repo_id=repo_id) from e
elif error_code == "EntryNotFound":
message = f"{response.status_code} Client Error." + "\n\n" + f"Entry Not Found for url: {response.url}."
raise _format(RemoteEntryNotFoundError, message, response) from e
raise _format(RemoteEntryNotFoundError, message, response, repo_type=repo_type, repo_id=repo_id) from e
elif error_code == "GatedRepo":
message = (
f"{response.status_code} Client Error." + "\n\n" + f"Cannot access gated repo for url {response.url}."
)
raise _format(GatedRepoError, message, response) from e
raise _format(GatedRepoError, message, response, repo_type=repo_type, repo_id=repo_id) from e
elif error_message == "Access to this resource is disabled.":
message = (
@ -686,12 +796,27 @@ def hf_raise_for_status(response: httpx.Response, endpoint_name: Optional[str] =
)
raise _format(DisabledRepoError, message, response) from e
elif (
error_code == "RepoNotFound"
and request_url is not None
and BUCKET_API_REGEX.search(request_url) is not None
):
message = (
f"{response.status_code} Client Error."
+ "\n\n"
+ f"Bucket Not Found for url: {response.url}."
+ "\nPlease make sure you specified the correct bucket id (namespace/name)."
+ "\nIf the bucket is private, make sure you are authenticated and your token has the required permissions."
)
raise _format(
BucketNotFoundError, message, response, bucket_id=_parse_bucket_id_from_url(request_url)
) from e
elif error_code == "RepoNotFound" or (
response.status_code == 401
and error_message != "Invalid credentials in Authorization header"
and response.request is not None
and response.request.url is not None
and REPO_API_REGEX.search(str(response.request.url)) is not None
and request_url is not None
and REPO_API_REGEX.search(request_url) is not None
):
# 401 is misleading as it is returned for:
# - private and gated repos if user is not authenticated
@ -704,10 +829,10 @@ def hf_raise_for_status(response: httpx.Response, endpoint_name: Optional[str] =
+ f"Repository Not Found for url: {response.url}."
+ "\nPlease make sure you specified the correct `repo_id` and"
" `repo_type`.\nIf you are trying to access a private or gated repo,"
" make sure you are authenticated. For more details, see"
" https://huggingface.co/docs/huggingface_hub/authentication"
" make sure you are authenticated and your token has the required permissions."
+ "\nFor more details, see https://huggingface.co/docs/huggingface_hub/authentication"
)
raise _format(RepositoryNotFoundError, message, response) from e
raise _format(RepositoryNotFoundError, message, response, repo_type=repo_type, repo_id=repo_id) from e
elif response.status_code == 400:
message = (
@ -779,7 +904,12 @@ def _warn_on_warning_headers(response: httpx.Response) -> None:
logger.warning(message)
def _format(error_type: type[HfHubHTTPError], custom_message: str, response: httpx.Response) -> HfHubHTTPError:
_HfHubHTTPErrorT = TypeVar("_HfHubHTTPErrorT", bound=HfHubHTTPError)
def _format(
error_type: type[_HfHubHTTPErrorT], custom_message: str, response: httpx.Response, **attrs: Any
) -> _HfHubHTTPErrorT:
server_errors = []
# Retrieve server error from header
@ -805,13 +935,20 @@ def _format(error_type: type[HfHubHTTPError], custom_message: str, response: htt
data = {}
error = data.get("error")
error_description = data.get("error_description")
if error is not None:
if isinstance(error, list):
# Case {'error': ['my error 1', 'my error 2']}
server_errors.extend(error)
elif error_description is not None:
# OAuth-style case {'error': 'invalid_grant', 'error_description': 'my description'}
server_errors.append(f"{error}: {error_description}")
else:
# Case {'error': 'my error'}
server_errors.append(error)
elif error_description is not None:
# Case {'error_description': 'my description'} (no 'error' field)
server_errors.append(error_description)
errors = data.get("errors")
if errors is not None:
@ -843,15 +980,22 @@ def _format(error_type: type[HfHubHTTPError], custom_message: str, response: htt
final_error_message += "\n" + server_message
else:
final_error_message += "\n\n" + server_message
# Prepare Request ID message
request_id = ""
request_id_message = ""
for header, label in (
(X_REQUEST_ID, "Request ID"),
(X_AMZN_TRACE_ID, "Amzn Trace ID"),
(X_AMZ_CF_ID, "Amz CF ID"),
):
value = response.headers.get(header)
if value:
request_id = str(value)
request_id_message = f" ({label}: {value})"
break
# Add Request ID
request_id = str(response.headers.get(X_REQUEST_ID, ""))
if request_id:
request_id_message = f" (Request ID: {request_id})"
else:
# Fallback to X-Amzn-Trace-Id
request_id = str(response.headers.get(X_AMZN_TRACE_ID, ""))
if request_id:
request_id_message = f" (Amzn Trace ID: {request_id})"
if request_id and request_id.lower() not in final_error_message.lower():
if "\n" in final_error_message:
newline_index = final_error_message.index("\n")
@ -862,7 +1006,21 @@ def _format(error_type: type[HfHubHTTPError], custom_message: str, response: htt
final_error_message += request_id_message
# Return
return error_type(final_error_message.strip(), response=response, server_message=server_message or None)
err = error_type(final_error_message.strip(), response=response, server_message=server_message or None)
for k, v in attrs.items():
setattr(err, k, v)
return err
# Request-body fields that carry credentials; redacted from debug-log curl commands (HF_DEBUG).
_SENSITIVE_BODY_KEYS = ("subject_token", "access_token", "refresh_token", "client_secret")
def _redact_sensitive_body(body: str) -> str:
"""Redact OAuth credential values from a JSON request body string."""
for key in _SENSITIVE_BODY_KEYS:
body = re.sub(rf'("{key}"\s*:\s*")[^"]*(")', r"\1<REDACTED>\2", body)
return body
def _curlify(request: httpx.Request) -> str:
@ -883,12 +1041,13 @@ def _curlify(request: httpx.Request) -> str:
v = "<TOKEN>" # Hide authorization header, no matter its value (can be Bearer, Key, etc.)
parts += [("-H", f"{k}: {v}")]
body: Optional[str] = None
body: str | None = None
try:
if request.content is not None:
body = request.content.decode("utf-8", errors="ignore")
if len(body) > 1000:
body = f"{body[:1000]} ... [truncated]"
body = _redact_sensitive_body(body)
except httpx.RequestNotRead:
body = "<streaming body>"
if body is not None:
@ -910,7 +1069,7 @@ def _curlify(request: httpx.Request) -> str:
RANGE_REGEX = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
def _adjust_range_header(original_range: Optional[str], resume_size: int) -> Optional[str]:
def _adjust_range_header(original_range: str | None, resume_size: int) -> str | None:
"""
Adjust HTTP Range header to account for resume position.
"""

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2019-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -14,7 +13,7 @@
# limitations under the License.
"""Contains utilities to handle pagination on Huggingface Hub."""
from typing import Iterable, Optional
from collections.abc import Iterable
import httpx
@ -48,5 +47,5 @@ def paginate(path: str, params: dict, headers: dict) -> Iterable:
next_page = _get_next_page(r)
def _get_next_page(response: httpx.Response) -> Optional[str]:
def _get_next_page(response: httpx.Response) -> str | None:
return response.links.get("next", {}).get("url")

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2025-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -16,12 +15,11 @@
import re
import time
from typing import Dict
RE_NUMBER_WITH_UNIT = re.compile(r"(\d+)([a-z]+)", re.IGNORECASE)
BYTE_UNITS: Dict[str, int] = {
BYTE_UNITS: dict[str, int] = {
"k": 1_000,
"m": 1_000_000,
"g": 1_000_000_000,
@ -29,7 +27,7 @@ BYTE_UNITS: Dict[str, int] = {
"p": 1_000_000_000_000_000,
}
TIME_UNITS: Dict[str, int] = {
TIME_UNITS: dict[str, int] = {
"s": 1,
"m": 60,
"h": 60 * 60,
@ -50,7 +48,7 @@ def parse_duration(value: str) -> int:
return _parse_with_unit(value, TIME_UNITS)
def _parse_with_unit(value: str, units: Dict[str, int]) -> int:
def _parse_with_unit(value: str, units: dict[str, int]) -> int:
"""Parse a numeric value with optional unit."""
stripped = value.strip()
if not stripped:
@ -73,6 +71,21 @@ def _parse_with_unit(value: str, units: Dict[str, int]) -> int:
return number * units[unit]
def format_duration(secs: int | None) -> str:
"""Format a duration in seconds as a short human-readable string (e.g. `"1m 32s"`, `"2h 15m"`, `"45s"`).
Returns `"--"` when `secs` is `None` so it can be used directly as a CLI table cell.
"""
if secs is None:
return "--"
secs = int(secs)
if secs < 60:
return f"{secs}s"
if secs < 3600:
return f"{secs // 60}m {secs % 60}s"
return f"{secs // 3600}h {(secs % 3600) // 60}m"
def format_timesince(ts: float) -> str:
"""Format timestamp in seconds into a human-readable string, relative to now.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -14,9 +13,10 @@
# limitations under the License.
"""Contains utilities to handle paths in Huggingface Hub."""
from collections.abc import Callable, Generator, Iterable
from fnmatch import fnmatch
from pathlib import Path
from typing import Callable, Generator, Iterable, Optional, TypeVar, Union
from typing import TypeVar
T = TypeVar("T")
@ -39,9 +39,9 @@ FORBIDDEN_FOLDERS = [".git", ".cache"]
def filter_repo_objects(
items: Iterable[T],
*,
allow_patterns: Optional[Union[list[str], str]] = None,
ignore_patterns: Optional[Union[list[str], str]] = None,
key: Optional[Callable[[T], str]] = None,
allow_patterns: list[str] | str | None = None,
ignore_patterns: list[str] | str | None = None,
key: Callable[[T], str] | None = None,
) -> Generator[T, None, None]:
"""Filter repo objects based on an allowlist and a denylist.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -15,6 +14,7 @@
"""Check presence of installed packages at runtime."""
import importlib.metadata
import importlib.util
import os
import platform
import sys
@ -316,32 +316,38 @@ def is_colab_enterprise() -> bool:
# Check how huggingface_hub has been installed
def installation_method() -> Literal["brew", "hf_installer", "unknown"]:
def installation_method() -> Literal["brew", "hf_installer", "pip", "unknown"]:
"""Return the installation method of the current environment.
- "hf_installer" if installed via the official installer script
- "brew" if installed via Homebrew
- "pip" if pip is available (default fallback for standard Python environments)
- "unknown" otherwise
"""
# hf_installer check must come first: the installer creates a venv using the
# system Python, which may be Homebrew's. Checking brew first would false-positive
# when the resolved sys.executable points to /opt/homebrew/... inside a venv.
if _is_hf_installer_installation():
return "hf_installer"
if _is_brew_installation():
return "brew"
elif _is_hf_installer_installation():
return "hf_installer"
else:
return "unknown"
if _is_pip_available():
return "pip"
return "unknown"
def _is_brew_installation() -> bool:
"""Check if running from a Homebrew installation.
Note: AI-generated by Claude.
Homebrew installs the `hf` formula into a Cellar directory and creates a
libexec virtualenv at e.g. /opt/homebrew/Cellar/hf/0.30.0/libexec/.
We check `sys.prefix` (the venv/prefix root) for "/Cellar/hf/" rather
than checking `sys.executable` the latter resolves to Homebrew's Python
(e.g. /opt/homebrew/Cellar/python@3.12/...) even for non-brew installs
when the system Python happens to come from Homebrew.
"""
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/")
prefix = str(Path(sys.prefix).resolve())
return "/Cellar/hf/" in prefix
def _is_hf_installer_installation() -> bool:
@ -356,6 +362,11 @@ def _is_hf_installer_installation() -> bool:
return marker.exists()
def _is_pip_available() -> bool:
"""Return `True` if pip is importable in the current environment."""
return importlib.util.find_spec("pip") is not None
def dump_environment_info() -> dict[str, Any]:
"""Dump information about the machine to help debugging issues.
@ -365,7 +376,7 @@ def dump_environment_info() -> dict[str, Any]:
- `transformers` (https://github.com/huggingface/transformers/blob/main/src/transformers/commands/env.py)
"""
from huggingface_hub import get_token, whoami
from huggingface_hub.utils import list_credential_helpers
from huggingface_hub.utils import is_agent, list_credential_helpers
token = get_token()
@ -400,6 +411,8 @@ def dump_environment_info() -> dict[str, Any]:
except Exception:
pass
info["Run by AI agent ?"] = "Yes" if is_agent() else "No"
# How huggingface_hub has been installed?
info["Installation method"] = installation_method()
@ -418,6 +431,7 @@ def dump_environment_info() -> dict[str, Any]:
info["HF_HUB_OFFLINE"] = constants.HF_HUB_OFFLINE
info["HF_HUB_DISABLE_TELEMETRY"] = constants.HF_HUB_DISABLE_TELEMETRY
info["HF_HUB_DISABLE_PROGRESS_BARS"] = constants.HF_HUB_DISABLE_PROGRESS_BARS
info["HF_HUB_DISABLE_SYMLINKS"] = constants.HF_HUB_DISABLE_SYMLINKS
info["HF_HUB_DISABLE_SYMLINKS_WARNING"] = constants.HF_HUB_DISABLE_SYMLINKS_WARNING
info["HF_HUB_DISABLE_EXPERIMENTAL_WARNING"] = constants.HF_HUB_DISABLE_EXPERIMENTAL_WARNING
info["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = constants.HF_HUB_DISABLE_IMPLICIT_TOKEN

View file

@ -2,7 +2,7 @@ import functools
import operator
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Literal, Optional
from typing import Literal
FILENAME_T = str
@ -97,7 +97,7 @@ class SafetensorsRepoMetadata:
of that data type.
"""
metadata: Optional[dict]
metadata: dict | None
sharded: bool
weight_map: dict[TENSOR_NAME_T, FILENAME_T] # tensor name -> filename
files_metadata: dict[FILENAME_T, SafetensorsFileMetadata] # filename -> metadata

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2021 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -17,10 +16,11 @@
import os
import subprocess
import sys
from collections.abc import Generator
from contextlib import contextmanager
from io import StringIO
from pathlib import Path
from typing import IO, Generator, Optional, Union
from typing import IO
from .logging import get_logger
@ -51,8 +51,8 @@ def capture_output() -> Generator[StringIO, None, None]:
def run_subprocess(
command: Union[str, list[str]],
folder: Optional[Union[str, Path]] = None,
command: str | list[str],
folder: str | Path | None = None,
check=True,
**kwargs,
) -> subprocess.CompletedProcess:
@ -84,8 +84,7 @@ def run_subprocess(
return subprocess.run(
command,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
capture_output=True,
check=check,
encoding="utf-8",
errors="replace", # if not utf-8, replace char by <20>
@ -96,8 +95,8 @@ def run_subprocess(
@contextmanager
def run_interactive_subprocess(
command: Union[str, list[str]],
folder: Optional[Union[str, Path]] = None,
command: str | list[str],
folder: str | Path | None = None,
**kwargs,
) -> Generator[tuple[IO[str], IO[str]], None, None]:
"""Run a subprocess in an interactive mode in a context manager.

View file

@ -1,6 +1,5 @@
from queue import Queue
from threading import Lock, Thread
from typing import Optional, Union
from urllib.parse import quote
from .. import constants, logging
@ -12,7 +11,7 @@ logger = logging.get_logger(__name__)
# Telemetry is sent by a separate thread to avoid blocking the main thread.
# A daemon thread is started once and consume tasks from the _TELEMETRY_QUEUE.
# If the thread stops for some reason -shouldn't happen-, we restart a new one.
_TELEMETRY_THREAD: Optional[Thread] = None
_TELEMETRY_THREAD: Thread | None = None
_TELEMETRY_THREAD_LOCK = Lock() # Lock to avoid starting multiple threads in parallel
_TELEMETRY_QUEUE: Queue = Queue()
@ -20,9 +19,9 @@ _TELEMETRY_QUEUE: Queue = Queue()
def send_telemetry(
topic: str,
*,
library_name: Optional[str] = None,
library_version: Optional[str] = None,
user_agent: Union[dict, str, None] = None,
library_name: str | None = None,
library_version: str | None = None,
user_agent: dict | str | None = None,
) -> None:
"""
Sends telemetry that helps track usage of different HF libraries.
@ -96,9 +95,9 @@ def _telemetry_worker():
def _send_telemetry_in_thread(
topic: str,
*,
library_name: Optional[str] = None,
library_version: Optional[str] = None,
user_agent: Union[dict, str, None] = None,
library_name: str | None = None,
library_version: str | None = None,
user_agent: dict | str | None = None,
) -> None:
"""Contains the actual data sending data to the Hub.

View file

@ -14,7 +14,35 @@
"""Contains utilities to print stuff to the terminal (styling, helpers)."""
import os
from typing import Union
import shutil
import sys
from ._detect_agent import is_agent
class StatusLine:
"""Minimal TTY status line for sync progress (stderr, single-line overwrite)."""
def __init__(self, enabled: bool = True):
self._active = enabled and sys.stderr.isatty()
def update(self, msg: str) -> None:
if not self._active:
return
width = shutil.get_terminal_size().columns
if len(msg) > width - 1:
msg = msg[: width - 4] + "..."
sys.stderr.write(f"\r\033[K\033[90m{msg}\033[0m")
sys.stderr.flush()
def done(self, msg: str) -> None:
if not self._active:
return
width = shutil.get_terminal_size().columns
if len(msg) > width - 1:
msg = msg[: width - 4] + "..."
sys.stderr.write(f"\r\033[K\033[90m{msg}\033[0m\n")
sys.stderr.flush()
class ANSI:
@ -28,6 +56,7 @@ class ANSI:
_green = "\u001b[32m"
_red = "\u001b[31m"
_reset = "\u001b[0m"
_underline = "\u001b[4m"
_yellow = "\u001b[33m"
@classmethod
@ -50,27 +79,40 @@ class ANSI:
def red(cls, s: str) -> str:
return cls._format(s, cls._bold + cls._red)
@classmethod
def underline(cls, s: str) -> str:
return cls._format(s, cls._underline)
@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"):
if os.environ.get("NO_COLOR") or is_agent():
# 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:
def tabulate(
rows: list[list[str | int]],
headers: list[str],
alignments: dict[str, str] | None = None,
) -> str:
"""
Inspired by:
- stackoverflow.com/a/8356620/593036
- stackoverflow.com/questions/9535954/printing-lists-as-tabular-data
"""
_ALIGN_MAP = {"left": "<", "right": ">"}
for row in rows:
if len(row) < len(headers):
raise IndexError(f"Row has {len(row)} values but expected {len(headers)} (headers: {headers})")
col_widths = [max(len(str(x)) for x in col) for col in zip(*rows, headers)]
row_format = ("{{:{}}} " * len(headers)).format(*col_widths)
col_aligns = [_ALIGN_MAP.get((alignments or {}).get(h, "left"), "<") for h in headers]
row_format = " ".join(f"{{:{a}{w}}}" for a, w in zip(col_aligns, col_widths))
lines = []
lines.append(row_format.format(*headers))
lines.append(row_format.format(*["-" * w for w in col_widths]))

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -14,15 +13,12 @@
# limitations under the License.
"""Handle typing imports based on system compatibility."""
import sys
from typing import Any, Callable, Literal, Optional, Type, TypeVar, Union, get_args, get_origin
from collections.abc import Callable
from types import UnionType
from typing import Any, Literal, TypeVar, Union, get_args, get_origin
UNION_TYPES: list[Any] = [Union]
if sys.version_info >= (3, 10):
from types import UnionType
UNION_TYPES += [UnionType]
UNION_TYPES: list[Any] = [Union, UnionType]
HTTP_METHOD_T = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"]
@ -33,7 +29,7 @@ CallableT = TypeVar("CallableT", bound=Callable)
_JSON_SERIALIZABLE_TYPES = (int, float, str, bool, type(None))
def is_jsonable(obj: Any, _visited: Optional[set[int]] = None) -> bool:
def is_jsonable(obj: Any, _visited: set[int] | None = None) -> bool:
"""Check if an object is JSON serializable.
This is a weak check, as it does not check for the actual JSON serialization, but only for the types of the object.
@ -78,7 +74,7 @@ def is_jsonable(obj: Any, _visited: Optional[set[int]] = None) -> bool:
_visited.discard(obj_id)
def is_simple_optional_type(type_: Type) -> bool:
def is_simple_optional_type(type_: type) -> bool:
"""Check if a type is optional, i.e. Optional[Type] or Union[Type, None] or Type | None, where Type is a non-composite type."""
if get_origin(type_) in UNION_TYPES:
union_args = get_args(type_)
@ -87,7 +83,7 @@ def is_simple_optional_type(type_: Type) -> bool:
return False
def unwrap_simple_optional_type(optional_type: Type) -> Type:
def unwrap_simple_optional_type(optional_type: type) -> type:
"""Unwraps a simple optional type, i.e. returns Type from Optional[Type]."""
for arg in get_args(optional_type):
if arg is not type(None):

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2022-present, the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -91,7 +90,7 @@ def validate_hf_hub_args(fn: CallableT) -> CallableT:
return _inner_fn # type: ignore
def validate_repo_id(repo_id: str) -> None:
def validate_repo_id(repo_id: str | None) -> None:
"""Validate `repo_id` is valid.
This is not meant to replace the proper validation made on the Hub but rather to
@ -121,6 +120,10 @@ def validate_repo_id(repo_id: str) -> None:
- https://github.com/huggingface/moon-landing/blob/main/server/lib/Names.ts#L27
- https://github.com/huggingface/moon-landing/blob/main/server/views/components/NewRepoForm/NewRepoForm.svelte#L138
"""
if repo_id is None:
# Repo id is not always required
return
if not isinstance(repo_id, str):
# Typically, a Path is not a repo_id
raise HFValidationError(f"Repo id must be a string, not {type(repo_id)}: '{repo_id}'.")

View file

@ -1,7 +1,7 @@
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Optional, TypedDict
from typing import TYPE_CHECKING, Literal, TypedDict
from .. import constants
from ..file_download import repo_folder_name
@ -43,7 +43,7 @@ def collect_local_files(root: Path) -> dict[str, Path]:
return {p.relative_to(root).as_posix(): p for p in root.rglob("*") if p.is_file()}
def _resolve_commit_hash_from_cache(storage_folder: Path, revision: Optional[str]) -> str:
def _resolve_commit_hash_from_cache(storage_folder: Path, revision: str | None) -> str:
"""
Resolve a commit hash from a cache repo folder and an optional revision.
"""
@ -141,9 +141,9 @@ def resolve_local_root(
*,
repo_id: str,
repo_type: str,
revision: Optional[str],
cache_dir: Optional[Path],
local_dir: Optional[Path],
revision: str | None,
cache_dir: Path | None,
local_dir: Path | None,
) -> tuple[Path, str]:
"""
Resolve the root directory to scan locally and the remote revision to verify.

View file

@ -1,12 +1,13 @@
import os
import threading
import time
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import httpx
from .. import constants
from . import get_session, hf_raise_for_status, validate_hf_hub_args
from . import hf_raise_for_status, http_backoff, validate_hf_hub_args
XET_CONNECTION_INFO_SAFETY_PERIOD = 60 # seconds
@ -32,9 +33,7 @@ class XetConnectionInfo:
endpoint: str
def parse_xet_file_data_from_response(
response: httpx.Response, endpoint: Optional[str] = None
) -> Optional[XetFileData]:
def parse_xet_file_data_from_response(response: httpx.Response, endpoint: str | None = None) -> XetFileData | None:
"""
Parse XET file metadata from an HTTP response.
@ -69,7 +68,7 @@ def parse_xet_file_data_from_response(
)
def parse_xet_connection_info_from_headers(headers: dict[str, str]) -> Optional[XetConnectionInfo]:
def parse_xet_connection_info_from_headers(headers: dict[str, str]) -> XetConnectionInfo | None:
"""
Parse XET connection info from the HTTP headers or return None if not found.
Args:
@ -122,16 +121,53 @@ def refresh_xet_connection_info(
return _fetch_xet_connection_info_with_url(file_data.refresh_route, headers)
@validate_hf_hub_args
def xet_connection_info_refresh_url(
*,
token_type: XetTokenType,
repo_id: str,
repo_type: str,
revision: str | None = None,
endpoint: str | None = None,
) -> str:
"""
Build the URL used to fetch or refresh a Xet access token for a given repo.
Args:
token_type (`XetTokenType`):
Type of the token to request: `"read"` or `"write"`.
repo_id (`str`):
A namespace (user or an organization) and a repo name separated by a `/`.
repo_type (`str`):
Type of the repo (e.g. `"model"`, `"dataset"`, `"space"`, `"bucket"`).
revision (`str`, `optional`):
The revision of the repo to get the token for.
endpoint (`str`, `optional`):
The endpoint to use for the request. Defaults to the Hub endpoint.
Returns:
`str`:
The fully-qualified URL of the token refresh endpoint.
"""
endpoint = endpoint if endpoint is not None else constants.ENDPOINT
url = f"{endpoint}/api/{repo_type}s/{repo_id}/xet-{token_type.value}-token"
if repo_type != "bucket" or revision is not None:
# On "bucket" repo type, the revision never needed => don't use it
# Otherwise, use the revision.
# Note: when creating a PR on a git-based repo, user needs write access but they don't know the revision in advance.
# => pass "/None" in URL and server will return a token for PR refs.
url += f"/{revision}"
return url
@validate_hf_hub_args
def fetch_xet_connection_info_from_repo_info(
*,
token_type: XetTokenType,
repo_id: str,
repo_type: str,
revision: Optional[str] = None,
revision: str | None = None,
headers: dict[str, str],
endpoint: Optional[str] = None,
params: Optional[dict[str, str]] = None,
endpoint: str | None = None,
params: dict[str, str] | None = None,
) -> XetConnectionInfo:
"""
Uses the repo info to request a xet access token from Hub.
@ -159,16 +195,22 @@ def fetch_xet_connection_info_from_repo_info(
[`ValueError`](https://docs.python.org/3/library/exceptions.html#ValueError)
If the Hub API response is improperly formatted.
"""
endpoint = endpoint if endpoint is not None else constants.ENDPOINT
url = f"{endpoint}/api/{repo_type}s/{repo_id}/xet-{token_type.value}-token/{revision}"
return _fetch_xet_connection_info_with_url(url, headers, params)
url = xet_connection_info_refresh_url(
token_type=token_type,
repo_id=repo_id,
repo_type=repo_type,
revision=revision,
endpoint=endpoint,
)
return _fetch_xet_connection_info_with_url(url, headers, params, cache_key_prefix=f"{repo_type}-{repo_id}")
@validate_hf_hub_args
def _fetch_xet_connection_info_with_url(
url: str,
headers: dict[str, str],
params: Optional[dict[str, str]] = None,
params: dict[str, str] | None = None,
cache_key_prefix: str | None = None,
) -> XetConnectionInfo:
"""
Requests the xet connection info from the supplied URL. This includes the
@ -193,14 +235,14 @@ def _fetch_xet_connection_info_with_url(
If the Hub API response is improperly formatted.
"""
# Check cache first
cache_key = _cache_key(url, headers, params)
cache_key = _cache_key(url, headers, params, prefix=cache_key_prefix)
cached_info = XET_CONNECTION_INFO_CACHE.get(cache_key)
if cached_info is not None:
if not _is_expired(cached_info):
return cached_info
# Fetch from server
resp = get_session().get(headers=headers, url=url, params=params)
resp = http_backoff("GET", url, headers=headers, params=params)
hf_raise_for_status(resp)
metadata = parse_xet_connection_info_from_headers(resp.headers) # type: ignore
@ -222,14 +264,107 @@ def _fetch_xet_connection_info_with_url(
return metadata
def _cache_key(url: str, headers: dict[str, str], params: Optional[dict[str, str]]) -> str:
def reset_xet_connection_info_cache_for_repo(repo_type: str | None, repo_id: str) -> None:
"""Reset the XET connection info cache for the given repo type and repo id.
Used when a repo is deleted.
"""
if repo_type is None:
repo_type = constants.REPO_TYPE_MODEL
prefix = f"{repo_type}-{repo_id}|"
for k in list(XET_CONNECTION_INFO_CACHE.keys()):
if k.startswith(prefix):
XET_CONNECTION_INFO_CACHE.pop(k, None)
def _cache_key(url: str, headers: dict[str, str], params: dict[str, str] | None, prefix: str | None = None) -> str:
"""Return a unique cache key for the given request parameters."""
lower_headers = {k.lower(): v for k, v in headers.items()} # casing is not guaranteed here
auth_header = lower_headers.get("authorization", "")
params_str = "&".join(f"{k}={v}" for k, v in sorted((params or {}).items(), key=lambda x: x[0]))
return f"{url}|{auth_header}|{params_str}"
return f"{prefix}|{url}|{auth_header}|{params_str}"
def _is_expired(connection_info: XetConnectionInfo) -> bool:
"""Check if the given XET connection info is expired."""
return connection_info.expiration_unix_epoch <= int(time.time()) + XET_CONNECTION_INFO_SAFETY_PERIOD
class XetSessionHolder:
"""Holds an optional XetSession; supports safe re-creation after sigint_abort or fork.
Thread-safe: a ``threading.Lock`` guards all state mutations, which matters
for free-threaded Python (3.14t) where multiple threads can race on ``get()``
or ``sigint_abort()`` without the GIL serialising them.
"""
def __init__(self):
self._lock = threading.Lock()
self._session = None
self._session_pid: int | None = None
def get(self):
"""Return the current session, creating one if needed.
Fork-safe: if the current process PID differs from the PID that created
the session (i.e. we are in a forked child), the old session is discarded
and a fresh session is created for this process.
"""
with self._lock:
current_pid = os.getpid()
if self._session is not None and self._session_pid != current_pid:
# Fork detected. Discard the parent's session; the Rust Drop will
# call discard_runtime() (std::mem::forget) rather than the normal
# shutdown path, so this returns immediately without blocking.
self._session = None
if self._session is None:
from hf_xet import XetSession
self._session = XetSession()
self._session_pid = current_pid
return self._session
def sigint_abort(self):
"""Abort the current session and clear it so the next get() creates a fresh one."""
with self._lock:
if self._session is not None:
try:
self._session.sigint_abort()
except Exception:
pass
self._session = None
self._session_pid = None
_GLOBAL_XET_HOLDER = XetSessionHolder()
def get_xet_session():
"""Return the global :class:`hf_xet.XetSession`, creating it on first call.
The session is shared across all calls within a process, just as the HTTP
client returned by :func:`~huggingface_hub.utils._http.get_session` is shared.
It is created lazily and is fork-safe and thread-safe.
"""
return _GLOBAL_XET_HOLDER.get()
def xet_headers_without_auth(headers: dict[str, str]) -> dict[str, str]:
"""Return a copy of headers with the authorization header removed.
Xet storage requests use a short-lived xet access token for auth, so the
Hub authorization header must not be forwarded to xet storage endpoints.
"""
return {key: value for key, value in headers.items() if key.lower() != "authorization"}
def abort_xet_session():
"""Abort the global xet session after a KeyboardInterrupt.
Cancels any in-flight Rust operation and clears the session so the next
call to :func:`get_xet_session` starts fresh (notebook-friendly).
"""
_GLOBAL_XET_HOLDER.sigint_abort()

View file

@ -1,7 +1,5 @@
from collections import OrderedDict
from typing import List
from hf_xet import PyItemProgressUpdate, PyTotalProgressUpdate
from typing import Any
from . import is_google_colab, is_notebook
from .tqdm import tqdm
@ -14,9 +12,10 @@ class XetProgressReporter:
Shows summary progress bars when running in notebooks or GUIs, and detailed per-file progress in console environments.
"""
def __init__(self, n_lines: int = 10, description_width: int = 30):
def __init__(self, n_lines: int = 10, description_width: int = 30, total_files: int | None = None):
self.n_lines = n_lines
self.description_width = description_width
self.total_files = total_files
self.per_file_progress = is_google_colab() or not is_notebook()
@ -42,9 +41,13 @@ class XetProgressReporter:
self.known_items: set[str] = set()
self.completed_items: set[str] = set()
# Track previous absolute values to compute increments
self._prev_bytes_completed: int = 0
self._prev_transfer_bytes_completed: int = 0
# Item bars (scrolling view)
self.item_state: OrderedDict[str, PyItemProgressUpdate] = OrderedDict()
self.current_bars: List = [None] * self.n_lines
self.item_state: OrderedDict[str, Any] = OrderedDict()
self.current_bars: list = [None] * self.n_lines
def format_desc(self, name: str, indent: bool) -> str:
"""
@ -64,9 +67,17 @@ class XetProgressReporter:
return f"{padding}{name.ljust(width)}"
def update_progress(self, total_update: PyTotalProgressUpdate, item_updates: list[PyItemProgressUpdate]):
def reset_for_next_commit(self):
"""Reset per-commit state so the reporter can be reused across multiple upload commits."""
self._prev_bytes_completed = 0
self._prev_transfer_bytes_completed = 0
self.known_items.clear()
self.completed_items.clear()
self.item_state.clear()
def update_progress(self, group_report, item_reports: dict):
# Update all the per-item values.
for item in item_updates:
for item in item_reports.values():
item_name = item.item_name
self.known_items.add(item_name)
@ -140,19 +151,25 @@ class XetProgressReporter:
s = tqdm.format_sizeof(speed) if speed is not None else "???"
return f"{s}B/s ".rjust(10, " ")
self.data_processing_bar.total = total_update.total_bytes
bytes_inc = group_report.total_bytes_completed - self._prev_bytes_completed
self._prev_bytes_completed = group_report.total_bytes_completed
transfer_inc = group_report.total_transfer_bytes_completed - self._prev_transfer_bytes_completed
self._prev_transfer_bytes_completed = group_report.total_transfer_bytes_completed
self.data_processing_bar.total = group_report.total_bytes
total_files_count = self.total_files if self.total_files is not None else len(self.known_items)
self.data_processing_bar.set_description(
self.format_desc(f"Processing Files ({len(self.completed_items)} / {len(self.known_items)})", False),
self.format_desc(f"Processing Files ({len(self.completed_items)} / {total_files_count})", False),
refresh=False,
)
self.data_processing_bar.set_postfix_str(postfix(total_update.total_bytes_completion_rate), refresh=False)
self.data_processing_bar.update(total_update.total_bytes_completion_increment)
self.data_processing_bar.set_postfix_str(postfix(group_report.total_bytes_completion_rate), refresh=False)
self.data_processing_bar.update(bytes_inc)
self.upload_bar.total = total_update.total_transfer_bytes
self.upload_bar.set_postfix_str(postfix(total_update.total_transfer_bytes_completion_rate), refresh=False)
self.upload_bar.update(total_update.total_transfer_bytes_completion_increment)
self.upload_bar.total = group_report.total_transfer_bytes
self.upload_bar.set_postfix_str(postfix(group_report.total_transfer_bytes_completion_rate), refresh=False)
self.upload_bar.update(transfer_inc)
def close(self, _success):
def close(self):
self.data_processing_bar.close()
self.upload_bar.close()

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2020 Optuna, Hugging Face
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -26,7 +25,6 @@ from logging import (
WARN, # NOQA
WARNING, # NOQA
)
from typing import Optional
from .. import constants
@ -77,7 +75,7 @@ def _reset_library_root_logger() -> None:
library_root_logger.setLevel(logging.NOTSET)
def get_logger(name: Optional[str] = None) -> logging.Logger:
def get_logger(name: str | None = None) -> logging.Logger:
"""
Returns a logger with the specified name. This function is not supposed
to be directly accessed by library users.

View file

@ -1,11 +1,11 @@
"""Utilities to efficiently compute the SHA 256 hash of a bunch of bytes."""
from typing import BinaryIO, Optional
from typing import BinaryIO
from .insecure_hashlib import sha1, sha256
def sha_fileobj(fileobj: BinaryIO, chunk_size: Optional[int] = None) -> bytes:
def sha_fileobj(fileobj: BinaryIO, chunk_size: int | None = None) -> bytes:
"""
Computes the sha256 hash of the given file object, by chunks of size `chunk_size`.

View file

@ -1,4 +1,3 @@
# coding=utf-8
# Copyright 2021 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@ -83,10 +82,12 @@ Group-based control:
import io
import logging
import os
import threading
import warnings
from collections.abc import Iterator
from contextlib import contextmanager, nullcontext
from pathlib import Path
from typing import ContextManager, Iterator, Optional, Union
from typing import ContextManager
from tqdm.auto import tqdm as old_tqdm
@ -101,11 +102,10 @@ from ..constants import HF_HUB_DISABLE_PROGRESS_BARS
# If `HF_HUB_DISABLE_PROGRESS_BARS` is not defined (None), it implies that users can manage
# progress bar visibility through code. By default, progress bars are turned on.
progress_bar_states: dict[str, bool] = {}
def disable_progress_bars(name: Optional[str] = None) -> None:
class disable_progress_bars:
"""
Disable progress bars either globally or for a specified group.
@ -113,6 +113,11 @@ def disable_progress_bars(name: Optional[str] = None) -> None:
If no group name is provided, all progress bars are disabled. The operation
respects the `HF_HUB_DISABLE_PROGRESS_BARS` environment variable's setting.
Works as both a regular call and a context manager:
disable_progress_bars() # disables until enable_progress_bars()
with disable_progress_bars(): # disables for the block, re-enables on exit
...
Args:
name (`str`, *optional*):
The name of the group for which to disable the progress bars. If None,
@ -121,23 +126,36 @@ def disable_progress_bars(name: Optional[str] = None) -> None:
Raises:
Warning: If the environment variable precludes changes.
"""
if HF_HUB_DISABLE_PROGRESS_BARS is False:
warnings.warn(
"Cannot disable progress bars: environment variable `HF_HUB_DISABLE_PROGRESS_BARS=0` is set and has priority."
)
return
if name is None:
progress_bar_states.clear()
progress_bar_states["_global"] = False
else:
keys_to_remove = [key for key in progress_bar_states if key.startswith(f"{name}.")]
for key in keys_to_remove:
del progress_bar_states[key]
progress_bar_states[name] = False
def __init__(self, name: str | None = None) -> None:
self.name = name
if HF_HUB_DISABLE_PROGRESS_BARS is False:
warnings.warn(
"Cannot disable progress bars: environment variable `HF_HUB_DISABLE_PROGRESS_BARS=0` is set and has priority."
)
self._should_reenable = False
return
self._should_reenable = not are_progress_bars_disabled(name)
if name is None:
progress_bar_states.clear()
progress_bar_states["_global"] = False
else:
keys_to_remove = [key for key in progress_bar_states if key.startswith(f"{name}.")]
for key in keys_to_remove:
del progress_bar_states[key]
progress_bar_states[name] = False
def __enter__(self) -> "disable_progress_bars":
return self
def __exit__(self, *exc) -> None:
if self._should_reenable:
enable_progress_bars(self.name)
def enable_progress_bars(name: Optional[str] = None) -> None:
def enable_progress_bars(name: str | None = None) -> None:
"""
Enable progress bars either globally or for a specified group.
@ -169,7 +187,7 @@ def enable_progress_bars(name: Optional[str] = None) -> None:
progress_bar_states[name] = True
def are_progress_bars_disabled(name: Optional[str] = None) -> bool:
def are_progress_bars_disabled(name: str | None = None) -> bool:
"""
Check if progress bars are disabled globally or for a specific group.
@ -198,7 +216,7 @@ def are_progress_bars_disabled(name: Optional[str] = None) -> bool:
return not progress_bar_states.get("_global", True)
def is_tqdm_disabled(log_level: int) -> Optional[bool]:
def is_tqdm_disabled(log_level: int) -> bool | None:
"""
Determine if tqdm progress bars should be disabled based on logging level and environment settings.
@ -233,8 +251,31 @@ class tqdm(old_tqdm):
raise
# Prevent tqdm's default multiprocessing write-lock from spawning a resource
# tracker subprocess via fork_exec(). That path fails when stderr has an invalid
# fd (e.g. Textual TUIs that return -1 from sys.stderr.fileno()). Inter-process
# bar coordination on the HF subclass is not a supported use case. See #4065.
tqdm.set_lock(threading.RLock())
class silent_tqdm:
"""Fake tqdm object that does nothing."""
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
def update(self, n: int | float | None = 1) -> None:
pass
@contextmanager
def tqdm_stream_file(path: Union[Path, str]) -> Iterator[io.BufferedReader]:
def tqdm_stream_file(path: Path | str) -> Iterator[io.BufferedReader]:
"""
Open a file as binary and wrap the `read` method to display a progress bar when it's streamed.
@ -267,7 +308,7 @@ def tqdm_stream_file(path: Union[Path, str]) -> Iterator[io.BufferedReader]:
f_read = f.read
def _inner_read(size: Optional[int] = -1) -> bytes:
def _inner_read(size: int | None = -1) -> bytes:
data = f_read(size)
pbar.update(len(data))
return data
@ -279,17 +320,39 @@ def tqdm_stream_file(path: Union[Path, str]) -> Iterator[io.BufferedReader]:
pbar.close()
def _create_progress_bar(*, cls: type[old_tqdm], log_level: int, name: str | None = None, **kwargs) -> old_tqdm:
"""Create a progress bar.
For our `tqdm` subclass (or subclasses of it): respects all disable signals
(`HF_HUB_DISABLE_PROGRESS_BARS`, `disable_progress_bars()`, log level) and uses
`disable=None` for TTY auto-detection (see https://github.com/huggingface/huggingface_hub/pull/2000),
unless `TQDM_POSITION=-1` forces bars on (https://github.com/huggingface/huggingface_hub/pull/2698).
For other classes: does not inject `disable` or `name`. the custom class is fully
responsible for its own behavior. Vanilla tqdm defaults to `disable=False` (bar shows).
Omits `name` which vanilla tqdm rejects with `TqdmKeyError`. See https://github.com/huggingface/huggingface_hub/issues/4050.
"""
# issubclass() crashes on non-class callables (e.g. functools.partial), guard with isinstance.
if not (isinstance(cls, type) and issubclass(cls, tqdm)):
return cls(**kwargs) # type: ignore[return-value]
# HF subclass: keep the historical log-level / TTY behavior. Group-based
# disabling is already handled in `tqdm.__init__`.
disable = is_tqdm_disabled(log_level)
return cls(disable=disable, name=name, **kwargs) # type: ignore[return-value]
def _get_progress_bar_context(
*,
desc: str,
log_level: int,
total: Optional[int] = None,
total: int | None = None,
initial: int = 0,
unit: str = "B",
unit_scale: bool = True,
name: Optional[str] = None,
tqdm_class: Optional[type[old_tqdm]] = None,
_tqdm_bar: Optional[tqdm] = None,
name: str | None = None,
tqdm_class: type[old_tqdm] | None = None,
_tqdm_bar: tqdm | None = None,
) -> ContextManager[tqdm]:
if _tqdm_bar is not None:
return nullcontext(_tqdm_bar)
@ -297,12 +360,13 @@ def _get_progress_bar_context(
# Makes it easier to use the same code path for both cases but in the later
# case, the progress bar is not closed when exiting the context manager.
return (tqdm_class or tqdm)( # type: ignore[return-value]
return _create_progress_bar( # type: ignore
cls=tqdm_class or tqdm,
log_level=log_level,
name=name,
unit=unit,
unit_scale=unit_scale,
total=total,
initial=initial,
desc=desc,
disable=is_tqdm_disabled(log_level=log_level),
name=name,
)