Voice et bot modif
This commit is contained in:
parent
189d56026b
commit
7333a22bcd
10774 changed files with 634644 additions and 933308 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# coding=utf-8
|
||||
# Copyright 2022-present, the HuggingFace Inc. team.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# coding=utf-8
|
||||
# Copyright 2019-present, the HuggingFace Inc. team.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}'.")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue