Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-huggingface-hub for
openSUSE:Factory checked in at 2026-09-11 18:02:19
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-huggingface-hub (Old)
and /work/SRC/openSUSE:Factory/.python-huggingface-hub.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-huggingface-hub"
Fri Sep 11 18:02:19 2026 rev:15 rq:1376987 version:1.31.0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-huggingface-hub/python-huggingface-hub.changes
2026-09-07 11:31:49.070229759 +0200
+++
/work/SRC/openSUSE:Factory/.python-huggingface-hub.new.1265/python-huggingface-hub.changes
2026-09-11 18:05:46.364973531 +0200
@@ -1,0 +2,18 @@
+Thu Sep 10 18:27:03 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 1.31.0:
+ * Sandboxes: custom Job labels for dedicated sandboxes
+ (--label KEY=VALUE, merged with internal labels)
+ * Downloads: retried header timeouts, tolerated missing HEAD
+ Content-Length, atomic snapshot_download refs writes, no cache
+ copies on hf download --dry-run --local-dir
+ * huggingface_hub.utils.httpx re-exports httpx for integrators
+ (types/exceptions; keep using get_session() for requests)
+ * HfFileSystem.get() rejects unsafe remote filenames (ValueError)
+ * CLI: fixed buckets rm --recursive prefix siblings, hf update
+ self-upgrade on Windows, shell-script extensions error, truncated
+ command descriptions
+ * Fixed CommitInfo copy/pickle, SliceFileObj iteration, dotenv # and
+ single-quote parsing
+
+-------------------------------------------------------------------
Old:
----
huggingface_hub-1.30.0.tar.gz
New:
----
huggingface_hub-1.31.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-huggingface-hub.spec ++++++
--- /var/tmp/diff_new_pack.ZEGf2a/_old 2026-09-11 18:05:47.101004301 +0200
+++ /var/tmp/diff_new_pack.ZEGf2a/_new 2026-09-11 18:05:47.102004342 +0200
@@ -23,7 +23,7 @@
%endif
%{?sle15_python_module_pythons}
Name: python-huggingface-hub
-Version: 1.30.0
+Version: 1.31.0
Release: 0
Summary: Client library for interaction with the huggingface hub
License: Apache-2.0
++++++ huggingface_hub-1.30.0.tar.gz -> huggingface_hub-1.31.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/PKG-INFO
new/huggingface_hub-1.31.0/PKG-INFO
--- old/huggingface_hub-1.30.0/PKG-INFO 2026-09-03 12:04:59.705272000 +0200
+++ new/huggingface_hub-1.31.0/PKG-INFO 2026-09-10 12:26:57.422171600 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: huggingface_hub
-Version: 1.30.0
+Version: 1.31.0
Summary: Client library to download and publish models, datasets and other
repos on the huggingface.co hub
Home-page: https://github.com/huggingface/huggingface_hub
Author: Hugging Face, Inc.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/__init__.py
new/huggingface_hub-1.31.0/src/huggingface_hub/__init__.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/__init__.py 2026-09-03
12:04:54.401985600 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/__init__.py 2026-09-10
12:26:54.607566400 +0200
@@ -46,7 +46,7 @@
from typing import TYPE_CHECKING
-__version__ = "1.30.0"
+__version__ = "1.31.0"
# Alphabetical order of definitions is ensured in tests
# WARNING: any comment added in this dictionary definition will be lost when
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/_sandbox.py
new/huggingface_hub-1.31.0/src/huggingface_hub/_sandbox.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/_sandbox.py 2026-09-03
12:04:54.403204200 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/_sandbox.py 2026-09-10
12:26:54.608407500 +0200
@@ -59,6 +59,8 @@
# `Sandbox.connect(id)` can recompute the token from any machine with no local
state.
NONCE_LABEL = "hf-sandbox-nonce"
+RESERVED_SANDBOX_LABELS = frozenset({SANDBOX_LABEL, MODE_LABEL, POOL_LABEL,
NONCE_LABEL})
+
DEFAULT_IMAGE = "python:3.12"
DEFAULT_IDLE_TIMEOUT = 10 * 60 # 10 minutes
@@ -527,6 +529,7 @@
volumes: List[Volume] | None = None,
namespace: str | None = None,
forward_hf_token: bool = False,
+ labels: dict[str, str] | None = None,
start_timeout: float = 120.0,
token: str | None = None,
) -> "Sandbox":
@@ -557,6 +560,8 @@
User or org namespace to run under (defaults to current user).
forward_hf_token (`bool`, *optional*, defaults to `False`):
If True, your HF token is injected as `HF_TOKEN` (opt-in).
+ labels (`dict[str, str]`, *optional*):
+ Labels to attach to the underlying HF Job.
start_timeout (`float`, *optional*, defaults to `120.0`):
Max seconds to wait for the sandbox to become ready.
token (`str`, *optional*):
@@ -566,6 +571,15 @@
`wget`/`curl` if available, otherwise read off an always-mounted
server bucket (which
adds ~2-3s to cold start, so shipping `wget`/`curl` keeps it fast).
"""
+ if labels is not None:
+ if not isinstance(labels, dict):
+ raise ValueError(f"`labels` must be a dict[str, str], got
{type(labels).__name__}")
+ for key, value in labels.items():
+ if not isinstance(key, str) or not isinstance(value, str):
+ raise ValueError("`labels` keys and values must be
strings")
+ if key in RESERVED_SANDBOX_LABELS:
+ raise ValueError(f"Label '{key}' is reserved by
huggingface_hub Sandbox and cannot be overridden.")
+
api = HfApi(token=token)
hf_token = _effective_token(api)
nonce = token_hex(16)
@@ -589,7 +603,7 @@
secrets=job_secrets,
flavor=flavor,
timeout=SANDBOX_MAX_LIFETIME,
- labels={SANDBOX_LABEL: "1", MODE_LABEL: MODE_DEDICATED,
NONCE_LABEL: nonce},
+ labels={**(labels or {}), SANDBOX_LABEL: "1", MODE_LABEL:
MODE_DEDICATED, NONCE_LABEL: nonce},
volumes=job_volumes or None,
expose=[SANDBOX_SERVER_PORT],
namespace=namespace,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/_snapshot_download.py
new/huggingface_hub-1.31.0/src/huggingface_hub/_snapshot_download.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/_snapshot_download.py
2026-09-03 12:04:54.403204200 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/_snapshot_download.py
2026-09-10 12:26:54.608407500 +0200
@@ -18,7 +18,13 @@
RepositoryNotFoundError,
RevisionNotFoundError,
)
-from .file_download import REGEX_COMMIT_HASH, DryRunFileInfo, hf_hub_download,
repo_folder_name
+from .file_download import (
+ REGEX_COMMIT_HASH,
+ DryRunFileInfo,
+ _cache_commit_hash_for_specific_revision,
+ hf_hub_download,
+ repo_folder_name,
+)
from .hf_api import HfApi, RepoFile
from .utils import OfflineModeIsEnabled, filter_repo_objects, logging,
validate_hf_hub_args
from .utils._xet_progress_reporting import (
@@ -412,14 +418,15 @@
# if passed revision is not identical to commit_hash
# then revision has to be a branch name or tag name.
# In that case store a ref (except if ResolvedRevision, in which case it's
already done).
- if not isinstance(revision, ResolvedRevision) and revision != commit_hash:
- ref_path = os.path.join(storage_folder, "refs", revision)
+ if not isinstance(revision, ResolvedRevision):
try:
- os.makedirs(os.path.dirname(ref_path), exist_ok=True)
- with open(ref_path, "w") as f:
- f.write(commit_hash)
+ # Skips the write if the ref is already up to date, and writes
atomically otherwise so that
+ # concurrent readers never observe a truncated (empty) ref file.
+ _cache_commit_hash_for_specific_revision(storage_folder, revision,
commit_hash)
except OSError as e:
- logger.warning(f"Ignored error while writing commit hash to
{ref_path}: {e}.")
+ logger.warning(
+ f"Ignored error while writing commit hash to
{os.path.join(storage_folder, 'refs', revision)}: {e}."
+ )
results: list[str | DryRunFileInfo] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/buckets.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/buckets.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/buckets.py
2026-09-03 12:04:54.405204300 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/buckets.py
2026-09-10 12:26:54.610227800 +0200
@@ -13,6 +13,7 @@
# limitations under the License.
"""Contains commands to interact with buckets via the CLI."""
+from collections.abc import Iterable, Iterator
from typing import Annotated
import click
@@ -21,6 +22,7 @@
from huggingface_hub._buckets import (
BUCKET_PREFIX,
BucketFile,
+ BucketFolder,
FilterMatcher,
_parse_bucket_uri,
)
@@ -230,6 +232,18 @@
out.table(items, alignments={"size": "right"})
+def _iter_under_prefix(items: Iterable[BucketFile | BucketFolder], prefix:
str) -> Iterator[BucketFile | BucketFolder]:
+ """Drop entries that are not `prefix` itself or below it.
+
+ The server matches `prefix` lexically, so listing "logs" also returns
"logs.json" or "logs_backup/...".
+ Filesystem semantics require path components => keep only what's actually
under the prefix.
+ """
+ prefix = prefix.rstrip("/")
+ for item in items:
+ if not prefix or item.path == prefix or
item.path.startswith(f"{prefix}/"):
+ yield item
+
+
def _list_files(
argument: str,
human_readable: bool,
@@ -244,10 +258,13 @@
api = get_hf_api(token=token)
parsed = _parse_bucket_uri(argument)
items = list(
- api.list_bucket_tree(
- parsed.id,
- prefix=parsed.path_in_repo or None,
- recursive=recursive,
+ _iter_under_prefix(
+ api.list_bucket_tree(
+ parsed.id,
+ prefix=parsed.path_in_repo or None,
+ recursive=recursive,
+ ),
+ parsed.path_in_repo,
)
)
@@ -416,10 +433,13 @@
status = out.status("Listing files from remote")
all_files: list[BucketFile] = []
- for item in api.list_bucket_tree(
- bucket_id,
- prefix=prefix or None,
- recursive=True,
+ for item in _iter_under_prefix(
+ api.list_bucket_tree(
+ bucket_id,
+ prefix=prefix or None,
+ recursive=True,
+ ),
+ prefix,
):
if isinstance(item, BucketFile):
all_files.append(item)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/download.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/download.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/download.py
2026-09-03 12:04:54.405204300 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/download.py
2026-09-10 12:26:54.610227800 +0200
@@ -208,15 +208,18 @@
if isinstance(result, DryRunFileInfo):
result = [result]
will_download = [r for r in result if r.will_download]
+ total_size = (
+ None if any(r.file_size is None for r in will_download) else
sum(r.file_size or 0 for r in will_download)
+ )
out.text(
f"[dry-run] Will download {len(will_download)} files"
f" (out of {len(result)})"
- f" totalling {_format_size(sum(r.file_size for r in
will_download))}."
+ f" totalling {_format_size(total_size) if total_size is not None
else 'an unknown size'}."
)
items = [
{
"file": info.filename,
- "size": _format_size(info.file_size) if info.will_download
else "-",
+ "size": _format_size(info.file_size) if info.will_download and
info.file_size is not None else "-",
}
for info in sorted(result, key=lambda x: x.filename)
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/extensions.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/extensions.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/extensions.py
2026-09-03 12:04:54.405204300 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/extensions.py
2026-09-10 12:26:54.610227800 +0200
@@ -375,11 +375,8 @@
out.confirm(f"'{short_name}' is an official Hugging Face extension
({owner}/{repo_name}). Install it?")
except ConfirmationError:
return None
- try:
- manifest = _install_extension(owner=owner, repo_name=repo_name,
short_name=short_name)
- return Path(manifest.executable_path).expanduser()
- except Exception:
- return None
+ manifest = _install_extension(owner=owner, repo_name=repo_name,
short_name=short_name)
+ return Path(manifest.executable_path).expanduser()
def _load_installed_extension_for_update(name: str) -> ExtensionManifest:
@@ -435,6 +432,11 @@
binary = None
if binary is not None:
+ if os.name == "nt":
+ raise CLIError(
+ f"'{owner}/{repo_name}' is a shell-script extension, which
is not supported on Windows. "
+ "Only Python extensions can be installed on Windows."
+ )
executable_path = _install_binary_extension(
extension_dir=extension_dir, short_name=short_name,
binary=binary
)
@@ -500,8 +502,7 @@
def _fetch_remote_binary(*, owner: str, repo_name: str, short_name: str) ->
bytes:
- executable_name = _get_executable_name(short_name)
- raw_url =
f"https://raw.githubusercontent.com/{owner}/{repo_name}/HEAD/{executable_name}"
+ raw_url =
f"https://raw.githubusercontent.com/{owner}/{repo_name}/HEAD/hf-{short_name}"
response = _github_request("GET", raw_url)
return response.content
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/jobs.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/jobs.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/jobs.py 2026-09-03
12:04:54.406204200 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/jobs.py 2026-09-10
12:26:54.611192700 +0200
@@ -917,7 +917,7 @@
examples=[
"hf jobs uv run --name my-script my_script.py",
"hf jobs uv run --detach my_script.py",
- "hf jobs uv run ml_training.py --flavor a10g-small",
+ "hf jobs uv run --flavor a10g-small ml_training.py",
"hf jobs uv run --with transformers train.py",
"hf jobs uv run -v hf://org/my-model:/data -v hf://buckets/org/b:/mnt
script.py",
],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/sandbox.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/sandbox.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/sandbox.py
2026-09-03 12:04:54.406204200 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/sandbox.py
2026-09-10 12:26:54.611192700 +0200
@@ -51,7 +51,7 @@
)
from ._framework import Argument, Option
from ._output import out
-from .jobs import FlavorOpt, NamespaceOpt
+from .jobs import FlavorOpt, LabelsOpt, NamespaceOpt, _parse_labels_map
sandbox_cli = typer_factory(help="Run and manage experimental sandboxes on
Hugging Face Jobs.")
@@ -81,6 +81,7 @@
"hf sandbox create",
"hf sandbox create ubuntu:24.04",
"hf sandbox create --flavor a10g-small",
+ "hf sandbox create --label controller-run=run-42",
"hf sandbox create --pool pool-ab12cd34ef56 --env LOG_LEVEL=debug",
],
)
@@ -97,6 +98,7 @@
] = None,
env: EnvOpt = None,
secrets: SecretsOpt = None,
+ label: LabelsOpt = None,
env_file: EnvFileOpt = None,
secrets_file: SecretsFileOpt = None,
volume: VolumesOpt = None,
@@ -121,6 +123,8 @@
raise CLIError("--pool fixes the image/flavor (and volumes aren't
supported); drop those options.")
if secrets or secrets_file:
raise CLIError("--pool can't encrypt secrets; pass them with
--env/--env-file instead.")
+ if label:
+ raise CLIError("--label is only supported for dedicated
sandboxes.")
sbx = SandboxPool.connect(pool, namespace=namespace,
token=token).create(
env=parse_env_map(env, env_file),
idle_timeout=idle,
@@ -140,6 +144,7 @@
volumes=parse_volumes(volume),
namespace=namespace,
forward_hf_token=forward_hf_token,
+ labels=_parse_labels_map(label),
token=token,
)
# Release the HTTP client (the sandbox keeps running)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/cli/system.py
new/huggingface_hub-1.31.0/src/huggingface_hub/cli/system.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/cli/system.py
2026-09-03 12:04:54.407204400 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/cli/system.py
2026-09-10 12:26:54.612192600 +0200
@@ -46,6 +46,18 @@
out.text(f"hf is up to date ({__version__})")
return
+ # Windows refuses to replace the `hf.exe` launcher of a running process:
pip would fail with a
+ # "file in use" error (WinError 32) *after* having uninstalled the current
version, leaving a dead
+ # `hf.exe` and no `huggingface_hub` module behind. Print the command
instead of running it, the same
+ # way pip refuses to upgrade itself on Windows.
+ if sys.platform == "win32" and installation_method() == "pip":
+ command = subprocess.list2cmdline([sys.executable, "-m", "pip",
"install", "-U", "huggingface_hub"])
+ out.error(
+ "On Windows, a pip-installed `hf` cannot update itself: pip is not
allowed to replace `hf.exe` while it "
+ f"is running. Run this command instead:\n {command}"
+ )
+ raise click.exceptions.Exit(code=1)
+
# The standalone installer installs the `hf-cli` skill by default. If it's
not installed at this
# point, the user opted out (or removed it): tell the installer to leave
it alone instead of
# silently undoing that choice.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/file_download.py
new/huggingface_hub-1.31.0/src/huggingface_hub/file_download.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/file_download.py
2026-09-03 12:04:54.408204300 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/file_download.py
2026-09-10 12:26:54.612192600 +0200
@@ -7,6 +7,7 @@
import time
import uuid
import warnings
+from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from typing import Any, BinaryIO, Literal, NoReturn, overload
@@ -180,8 +181,9 @@
Args:
commit_hash (`str`):
The commit_hash related to the file.
- file_size (`int`):
- Size of the file. In case of an LFS file, contains the size of the
actual LFS file, not the pointer.
+ file_size (`int`, *optional*):
+ Size of the file, if known. In case of an LFS file, contains the
size of the actual LFS file, not the
+ pointer.
filename (`str`):
Name of the file in the repo.
is_cached (`bool`):
@@ -192,7 +194,7 @@
"""
commit_hash: str
- file_size: int
+ file_size: int | None
filename: str
local_path: str
is_cached: bool
@@ -374,100 +376,105 @@
" Install `hf_xet` with `pip install hf_xet` for xet-powered
downloads."
)
- with http_stream_backoff(
- method="GET",
- url=url,
- headers=headers,
- timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
- retry_on_exceptions=(),
- retry_on_status_codes=(408, 429),
- ) as response:
- hf_raise_for_status(response)
-
- # If we requested a Range but got 200 back, the server ignored our
Range header
- # (e.g. CloudFront with Accept-Encoding: gzip). Reset file to avoid
corruption.
- if resume_size > 0 and response.status_code == 200:
- temp_file.seek(0)
- temp_file.truncate()
- if _tqdm_bar is not None:
- # When the progress bar is reused across retries, its counter
has already been advanced by `resume_size`
- # worth of chunks from earlier attempts. Those bytes are gone
from disk now, so roll the counter back
- # to keep the upcoming full re-download from double-counting
(e.g. ending at 130/100 on a 100-byte file).
- _tqdm_bar.update(-resume_size)
- if callable(update_transfer := getattr(_tqdm_bar,
"update_transfer", None)):
- update_transfer(-resume_size)
- resume_size = 0
-
- total: int | None = _get_file_length_from_http_response(response)
- if total is None:
- # Hub serves compressible text files (e.g. vocab.json) with
`Content-Encoding: gzip` and
- # `Transfer-Encoding: chunked`, so the response carries no
`Content-Length`. Fall back to the caller's
- # `expected_size` (always known from the metadata HEAD on the
hf_hub path) so the progress bar, and any
- # aggregating wrapper such as snapshot_download's
`_AggregatedTqdm` — still sees the file size.
- total = expected_size
-
- if displayed_filename is None:
- displayed_filename = url
- content_disposition = response.headers.get("Content-Disposition")
- if content_disposition is not None:
- match = HEADER_FILENAME_PATTERN.search(content_disposition)
- if match is not None:
- # Means file is on CDN
- displayed_filename = match.groupdict()["filename"]
-
- # Truncate filename if too long to display
- if len(displayed_filename) > 40:
- displayed_filename = f"(…){displayed_filename[-40:]}"
-
- consistency_error_message = (
- f"Consistency check failed: file should be of size {expected_size}
but has size"
- f" {{actual_size}} ({displayed_filename}).\nThis is usually due to
network issues while downloading the file."
- " Please retry with `force_download=True`."
- )
- progress_cm = _get_progress_bar_context(
- desc=displayed_filename,
- log_level=logger.getEffectiveLevel(),
- total=total,
- initial=resume_size,
- name="huggingface_hub.http_get",
- tqdm_class=tqdm_class,
- _tqdm_bar=_tqdm_bar,
- )
-
- with progress_cm as progress:
- new_resume_size = resume_size
- try:
- for chunk in
response.iter_bytes(chunk_size=constants.DOWNLOAD_CHUNK_SIZE):
- if chunk: # filter out keep-alive new chunks
- progress.update(len(chunk))
- if callable(update_transfer := getattr(progress,
"update_transfer", None)):
- update_transfer(len(chunk))
- temp_file.write(chunk)
- new_resume_size += len(chunk)
- # Some data has been downloaded from the server so we
reset the number of retries.
- _nb_retries = 5
- except (httpx.ConnectError, httpx.TimeoutException,
httpx.RemoteProtocolError) as e:
- # If ConnectionError (SSLError), ReadTimeout, or
RemoteProtocolError (peer closed the connection before
- # sending the complete body) happen while streaming data from
the server, it is most likely a transient
- # error (network outage?). We log a warning message and try to
resume the download a few times before
- # giving up. The retry mechanism is basic but should be enough
in most cases.
- if _nb_retries <= 0:
- logger.warning("Error while downloading from %s: %s\nMax
retries exceeded.", url, str(e))
- raise
- logger.warning("Error while downloading from %s: %s\nTrying to
resume download...", url, str(e))
- time.sleep(1)
- return http_get(
+ # Keep the response and progress bar open while recursive retries reuse
them.
+ with ExitStack() as stack:
+ progress = _tqdm_bar
+ new_resume_size = resume_size
+ try:
+ response = stack.enter_context(
+ http_stream_backoff(
+ method="GET",
url=url,
- temp_file=temp_file,
- resume_size=new_resume_size,
- headers=initial_headers,
- expected_size=expected_size,
- tqdm_class=tqdm_class,
- _nb_retries=_nb_retries - 1,
- # Reuse the existing progress bar across retries so a
custom `tqdm_class` (e.g. snapshot_download's `_AggregatedTqdm`,
- # which mutates a shared parent bar in `__init__`) is not
re-instantiated and does not double-count `total`/`initial`.
- _tqdm_bar=progress,
+ headers=headers,
+ timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
+ retry_on_exceptions=(),
+ retry_on_status_codes=(408, 429),
)
+ )
+ hf_raise_for_status(response)
+
+ # If we requested a Range but got 200 back, the server ignored our
Range header
+ # (e.g. CloudFront with Accept-Encoding: gzip). Reset file to
avoid corruption.
+ if resume_size > 0 and response.status_code == 200:
+ temp_file.seek(0)
+ temp_file.truncate()
+ if _tqdm_bar is not None:
+ # When the progress bar is reused across retries, its
counter has already been advanced by `resume_size`
+ # worth of chunks from earlier attempts. Those bytes are
gone from disk now, so roll the counter back
+ # to keep the upcoming full re-download from
double-counting (e.g. ending at 130/100 on a 100-byte file).
+ _tqdm_bar.update(-resume_size)
+ if callable(update_transfer := getattr(_tqdm_bar,
"update_transfer", None)):
+ update_transfer(-resume_size)
+ resume_size = 0
+
+ total: int | None = _get_file_length_from_http_response(response)
+ if expected_size is None:
+ expected_size = total
+ elif total is None:
+ # Hub serves compressible text files (e.g. vocab.json) with
`Content-Encoding: gzip` and
+ # `Transfer-Encoding: chunked`, so the response carries no
`Content-Length`. Fall back to the caller's
+ # `expected_size` (always known from the metadata HEAD on the
hf_hub path) so the progress bar, and any
+ # aggregating wrapper such as snapshot_download's
`_AggregatedTqdm` — still sees the file size.
+ total = expected_size
+
+ if displayed_filename is None:
+ displayed_filename = url
+ content_disposition =
response.headers.get("Content-Disposition")
+ if content_disposition is not None:
+ match = HEADER_FILENAME_PATTERN.search(content_disposition)
+ if match is not None:
+ # Means file is on CDN
+ displayed_filename = match.groupdict()["filename"]
+
+ # Truncate filename if too long to display
+ if len(displayed_filename) > 40:
+ displayed_filename = f"(…){displayed_filename[-40:]}"
+
+ consistency_error_message = (
+ f"Consistency check failed: file should be of size
{expected_size} but has size"
+ f" {{actual_size}} ({displayed_filename}).\nThis is usually
due to network issues while downloading the file."
+ " Please retry with `force_download=True`."
+ )
+ progress_cm = _get_progress_bar_context(
+ desc=displayed_filename,
+ log_level=logger.getEffectiveLevel(),
+ total=total,
+ initial=resume_size,
+ name="huggingface_hub.http_get",
+ tqdm_class=tqdm_class,
+ _tqdm_bar=_tqdm_bar,
+ )
+
+ progress = stack.enter_context(progress_cm)
+ new_resume_size = resume_size
+ for chunk in
response.iter_bytes(chunk_size=constants.DOWNLOAD_CHUNK_SIZE):
+ if chunk: # filter out keep-alive new chunks
+ progress.update(len(chunk))
+ if callable(update_transfer := getattr(progress,
"update_transfer", None)):
+ update_transfer(len(chunk))
+ temp_file.write(chunk)
+ new_resume_size += len(chunk)
+ # Some data has been downloaded from the server so we
reset the number of retries.
+ _nb_retries = 5
+ except (httpx.ConnectError, httpx.TimeoutException,
httpx.RemoteProtocolError) as e:
+ # Retry transient failures both when opening the stream and while
reading its body.
+ if _nb_retries <= 0:
+ logger.warning("Error while downloading from %s: %s\nMax
retries exceeded.", url, str(e))
+ raise
+ logger.warning("Error while downloading from %s: %s\nTrying to
resume download...", url, str(e))
+ time.sleep(1)
+ return http_get(
+ url=url,
+ temp_file=temp_file,
+ resume_size=new_resume_size,
+ headers=initial_headers,
+ expected_size=expected_size,
+ tqdm_class=tqdm_class,
+ _nb_retries=_nb_retries - 1,
+ # Reuse the existing progress bar across retries so a custom
`tqdm_class` (e.g. snapshot_download's `_AggregatedTqdm`,
+ # which mutates a shared parent bar in `__init__`) is not
re-instantiated and does not double-count `total`/`initial`.
+ _tqdm_bar=progress,
+ )
if expected_size is not None and expected_size != temp_file.tell():
raise OSError(
@@ -1174,11 +1181,10 @@
if head_call_error is not None:
_raise_on_head_call_error(head_call_error, force_download,
local_files_only)
- # From now on, etag, commit_hash, url and size are not None.
+ # From now on, etag, commit_hash and url are not None.
assert etag is not None, "etag must have been retrieved from server"
assert commit_hash is not None, "commit_hash must have been retrieved from
server"
assert url_to_download is not None, "file location must have been
retrieved from server"
- assert expected_size is not None, "expected_size must have been retrieved
from server"
blob_path = os.path.join(storage_folder, "blobs", etag)
pointer_path = _get_pointer_path(storage_folder, commit_hash,
relative_filename)
@@ -1380,11 +1386,10 @@
if head_call_error is not None:
_raise_on_head_call_error(head_call_error, force_download,
local_files_only)
- # From now on, etag, commit_hash, url and size are not None.
+ # From now on, etag, commit_hash and url are not None.
assert etag is not None, "etag must have been retrieved from server"
assert commit_hash is not None, "commit_hash must have been retrieved from
server"
assert url_to_download is not None, "file location must have been
retrieved from server"
- assert expected_size is not None, "expected_size must have been retrieved
from server"
# Local file exists => check if it's up-to-date
if not force_download and paths.file_path.is_file():
@@ -1434,10 +1439,6 @@
repo_type=repo_type,
)
if isinstance(cached_path, str):
- with WeakFileLock(paths.lock_path):
- paths.file_path.parent.mkdir(parents=True, exist_ok=True)
- shutil.copyfile(cached_path, paths.file_path)
- write_download_metadata(local_dir=local_dir, filename=filename,
commit_hash=commit_hash, etag=etag)
if dry_run:
return DryRunFileInfo(
commit_hash=commit_hash,
@@ -1447,6 +1448,10 @@
local_path=str(paths.file_path),
will_download=False,
)
+ with WeakFileLock(paths.lock_path):
+ paths.file_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(cached_path, paths.file_path)
+ write_download_metadata(local_dir=local_dir, filename=filename,
commit_hash=commit_hash, etag=etag)
return str(paths.file_path)
if dry_run:
@@ -1671,7 +1676,7 @@
|
# Or the metadata is returned as
# `(url_to_download, etag, commit_hash, expected_size, xet_file_data,
None)`
- tuple[str, str, str, int, XetFileData | None, None]
+ tuple[str, str, str, int | None, XetFileData | None, None]
):
"""Get metadata for a file on the Hub, safely handling network issues.
@@ -1763,12 +1768,11 @@
"Distant resource does not have an ETag, we won't be able
to reliably ensure reproducibility."
)
- # Size must exist
+ # Xet downloads require a known size, but regular HTTP downloads
can recover it from the GET response.
expected_size = metadata.size
- if expected_size is None:
- raise FileMetadataError("Distant resource does not have a
Content-Length.")
-
xet_file_data = metadata.xet_file_data
+ if expected_size is None and xet_file_data is not None and
is_xet_available():
+ raise FileMetadataError("Distant resource does not have a
Content-Length.")
# In case of a redirect, save an extra redirect on the request.get
call,
# and ensure we download the exact atomic version even if it
changed
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/src/huggingface_hub/hf_api.py
new/huggingface_hub-1.31.0/src/huggingface_hub/hf_api.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/hf_api.py 2026-09-03
12:04:54.409204500 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/hf_api.py 2026-09-10
12:26:54.615192700 +0200
@@ -556,9 +556,20 @@
pr_revision: str | None = field(init=False)
pr_num: int | None = field(init=False)
- def __new__(cls, *args, commit_url: str, **kwargs):
+ def __new__(cls, commit_url: str, *args, **kwargs):
return str.__new__(cls, commit_url)
+ def __reduce__(self):
+ # without this, pickle/copy rebuild the instance from its string value
only, losing the attributes
+ return self.__class__, (
+ self.commit_url,
+ self.commit_message,
+ self.commit_description,
+ self.oid,
+ self._endpoint,
+ self.pr_url,
+ )
+
def __post_init__(self):
"""Populate pr-related fields after initialization.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/hf_file_system.py
new/huggingface_hub-1.31.0/src/huggingface_hub/hf_file_system.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/hf_file_system.py
2026-09-03 12:04:54.410204400 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/hf_file_system.py
2026-09-10 12:26:54.615192700 +0200
@@ -20,6 +20,7 @@
from . import constants
from ._commit_api import CommitOperationCopy, CommitOperationDelete
+from ._local_folder import _validate_relative_filename
from .errors import (
BucketNotFoundError,
EntryNotFoundError,
@@ -1094,6 +1095,10 @@
"""
revision = kwargs.get("revision")
+ resolve_remote_path = self.resolve_path(rpath, revision=revision)
+ # Recursive downloads map remote filenames to local paths, including
on Windows.
+ # Validate before creating directories, opening files, or delegating
to fsspec.
+ _validate_relative_filename(resolve_remote_path.path)
unhandled_kwargs = set(kwargs.keys()) - {"revision"}
if not isinstance(callback, (NoOpCallback, TqdmCallback)) or
len(unhandled_kwargs) > 0:
# for now, let's not handle custom callbacks
@@ -1118,7 +1123,6 @@
initial_pos = outfile.tell()
# Custom implementation of `get_file` to use `http_get`.
- resolve_remote_path = self.resolve_path(rpath, revision=revision)
expected_size = self.info(rpath, revision=revision)["size"]
callback.set_size(expected_size)
try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/utils/__init__.py
new/huggingface_hub-1.31.0/src/huggingface_hub/utils/__init__.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/utils/__init__.py
2026-09-03 12:04:54.415740700 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/utils/__init__.py
2026-09-10 12:26:54.619972700 +0200
@@ -13,6 +13,8 @@
# limitations under the License
# ruff: noqa: F401
+import httpx # for forward compatibility - will be httpx2 in huggingface_hub
v2.x
+
from huggingface_hub.errors import (
BadRequestError,
BucketNotFoundError,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/utils/_dotenv.py
new/huggingface_hub-1.31.0/src/huggingface_hub/utils/_dotenv.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/utils/_dotenv.py
2026-09-03 12:04:54.415851600 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/utils/_dotenv.py
2026-09-10 12:26:54.620037000 +0200
@@ -29,15 +29,26 @@
^\s*
(?:export[^\S\n]+)? # optional export
([A-Za-z_][A-Za-z0-9_]*) # key
- [^\S\n]*(=)?[^\S\n]*
- ( # value group
+ (?:
+ [^\S\n]*
+ (=) # equal sign
(?:
- '(?:\\'|[^'])*' # single-quoted value
- | \"(?:\\\"|[^\"])*\" # double-quoted value
- | [^#\n\r]+? # unquoted value
+ [^\S\n]*
+ ( # quoted value
+ '(?:\\'|[^'])*' # single-quoted
+ | \"(?:\\\"|[^\"])*\" # double-quoted
+ )
+ [^\S\n]*(?:\#[^\n\r]*)? # inline comment (needs no preceding
whitespace after a quote)
+ |
+ (?:[^\S\n]+(?!\#))? # whitespace after '=' also
separates an inline comment
+ ( # unquoted value (may contain '#')
+ [^\n\r]*?
+ )
+ (?:[^\S\n]+\#[^\n\r]*)? # inline comment (must be preceded
by whitespace)
)
- )?
- [^\S\n]*(?:\#.*)?$ # optional inline comment
+ |
+ [^\S\n]*(?:\#[^\n\r]*)? # bare key (no '='), with an
optional inline comment
+ )$
""",
re.VERBOSE,
)
@@ -52,12 +63,15 @@
key = match.group(1)
val = None
if match.group(2): # if there is '='
- raw_val = match.group(3) or ""
+ raw_val = match.group(3) or match.group(4) or ""
val = raw_val.strip()
# Remove surrounding quotes if quoted
- if (val.startswith('"') and val.endswith('"')) or
(val.startswith("'") and val.endswith("'")):
- escapes = _DOUBLE_QUOTE_ESCAPES if raw_val.startswith('"')
else _ESCAPES
- val = _unescape(val[1:-1], escapes)
+ if val.startswith('"') and val.endswith('"'):
+ # Double-quoted values expand escape sequences (\n, \t,
\", \\, \$).
+ val = _unescape(val[1:-1], _DOUBLE_QUOTE_ESCAPES)
+ elif val.startswith("'") and val.endswith("'"):
+ # Single-quoted values are kept verbatim: no escape
expansion.
+ val = val[1:-1]
elif environ is not None:
# Get it from the current environment
val = environ.get(key)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub/utils/_lfs.py
new/huggingface_hub-1.31.0/src/huggingface_hub/utils/_lfs.py
--- old/huggingface_hub-1.30.0/src/huggingface_hub/utils/_lfs.py
2026-09-03 12:04:54.416204500 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub/utils/_lfs.py
2026-09-10 12:26:54.620192800 +0200
@@ -106,4 +106,5 @@
return self.fileobj.seek(offset, whence) - self.seek_from
def __iter__(self):
- yield self.read(n=4 * 1024 * 1024)
+ while data := self.read(n=4 * 1024 * 1024):
+ yield data
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/src/huggingface_hub.egg-info/PKG-INFO
new/huggingface_hub-1.31.0/src/huggingface_hub.egg-info/PKG-INFO
--- old/huggingface_hub-1.30.0/src/huggingface_hub.egg-info/PKG-INFO
2026-09-03 12:04:59.616270800 +0200
+++ new/huggingface_hub-1.31.0/src/huggingface_hub.egg-info/PKG-INFO
2026-09-10 12:26:57.372171900 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: huggingface_hub
-Version: 1.30.0
+Version: 1.31.0
Summary: Client library to download and publish models, datasets and other
repos on the huggingface.co hub
Home-page: https://github.com/huggingface/huggingface_hub
Author: Hugging Face, Inc.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/tests/test_buckets_cli.py
new/huggingface_hub-1.31.0/tests/test_buckets_cli.py
--- old/huggingface_hub-1.30.0/tests/test_buckets_cli.py 2026-09-03
12:04:54.419241200 +0200
+++ new/huggingface_hub-1.31.0/tests/test_buckets_cli.py 2026-09-10
12:26:54.622873500 +0200
@@ -302,6 +302,40 @@
assert _remote_files(api, bucket_write) == {"keep.txt"}
+def test_rm_recursive_path_boundary(api: HfApi, bucket_write: str):
+ """'hf buckets rm prefix --recursive' does not remove lexical siblings of
the prefix."""
+ api.batch_bucket_files(
+ bucket_write,
+ add=[
+ (b"a", "logs/a.log"),
+ (b"b", "logs/b.log"),
+ (b"json", "logs.json"),
+ (b"backup", "logs_backup/a.log"),
+ (b"x", "logsx/c.log"),
+ ],
+ )
+
+ result = cli(f"hf buckets rm {bucket_write}/logs --recursive --yes")
+ assert result.exit_code == 0
+ assert "2 file(s)" in result.output
+
+ assert _remote_files(api, bucket_write) == {"logs.json",
"logs_backup/a.log", "logsx/c.log"}
+
+
+def test_rm_recursive_trailing_slash(api: HfApi, bucket_write: str):
+ """'hf buckets rm prefix/ --recursive' behaves like the slash-less form."""
+ api.batch_bucket_files(
+ bucket_write,
+ add=[(b"a", "logs/a.log"), (b"json", "logs.json")],
+ )
+
+ result = cli(f"hf buckets rm {bucket_write}/logs/ --recursive --yes")
+ assert result.exit_code == 0
+ assert "1 file(s)" in result.output
+
+ assert _remote_files(api, bucket_write) == {"logs.json"}
+
+
def test_rm_recursive_dry_run(api: HfApi, bucket_write: str):
"""'hf buckets rm prefix/ --recursive --dry-run' previews without
deleting."""
api.batch_bucket_files(
@@ -651,6 +685,21 @@
)
+def test_list_files_with_prefix_path_boundary(api: HfApi, bucket_write: str):
+ """A prefix is scoped to path components: lexical siblings are not
listed."""
+ api.batch_bucket_files(
+ bucket_write,
+ add=[
+ (b"a", "logs/a.log"),
+ (b"json", "logs.json"),
+ (b"backup", "logs_backup/a.log"),
+ (b"x", "logsx/c.log"),
+ ],
+ )
+
+ _check_list_output(f"hf buckets list {bucket_write}/logs -R --quiet",
["logs/a.log"])
+
+
def test_list_files_with_hf_prefix(tree_bucket: str):
"""hf://buckets/ format works the same as short format."""
_check_list_output(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/tests/test_cli.py
new/huggingface_hub-1.31.0/tests/test_cli.py
--- old/huggingface_hub-1.30.0/tests/test_cli.py 2026-09-03
12:04:54.420204400 +0200
+++ new/huggingface_hub-1.31.0/tests/test_cli.py 2026-09-10
12:26:54.623192500 +0200
@@ -5009,6 +5009,8 @@
with (
patch("huggingface_hub.cli.system._fetch_latest_pypi_version",
return_value="99.0.0"),
patch("huggingface_hub.cli.system.subprocess.call",
return_value=0),
+ # `hf update` refuses to self-update a pip install on Windows:
pretend we're not on Windows.
+ patch("huggingface_hub.cli.system.sys.platform", "linux"),
patch("huggingface_hub.cli.system.run_update", return_value=0) as
mock_run_update,
):
yield mock_run_update
@@ -5138,6 +5140,7 @@
("Contribute to huggingface/hf-demo development by creating an
account on GitHub.", None),
],
)
+ @pytest.mark.skipif(os.name == "nt", reason="Shell-script extensions are
not supported on Windows.")
def test_install_uses_head_refs_and_a_single_api_call(
self, github: _FakeGitHubSession, about: str, expected_description:
str | None
) -> None:
@@ -5159,6 +5162,7 @@
raw_urls = [url for url in github.urls if url.startswith(raw_prefix)]
assert raw_urls and
all(url.removeprefix(raw_prefix).startswith("HEAD/") for url in raw_urls)
+ @pytest.mark.skipif(os.name == "nt", reason="Shell-script extensions are
not supported on Windows.")
def test_install_completes_when_the_api_quota_is_exhausted(self, github:
_FakeGitHubSession) -> None:
# The extension itself comes from the CDN, so only the optional
version marker is lost.
github.responses = {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/tests/test_file_download.py
new/huggingface_hub-1.31.0/tests/test_file_download.py
--- old/huggingface_hub-1.30.0/tests/test_file_download.py 2026-09-03
12:04:54.421204600 +0200
+++ new/huggingface_hub-1.31.0/tests/test_file_download.py 2026-09-10
12:26:54.624192700 +0200
@@ -26,7 +26,7 @@
from huggingface_hub import HfApi, constants
from huggingface_hub._local_folder import write_download_metadata
-from huggingface_hub.errors import EntryNotFoundError, GatedRepoError,
LocalEntryNotFoundError
+from huggingface_hub.errors import EntryNotFoundError, FileMetadataError,
GatedRepoError, LocalEntryNotFoundError
from huggingface_hub.file_download import (
_CACHED_NO_EXIST,
HfFileMetadata,
@@ -70,6 +70,52 @@
DATASET_SAMPLE_PY_FILE = "custom_squad.py"
[email protected]("use_local_dir", [False, True])
[email protected]("xet_mode", ["no_metadata", "disabled",
"not_installed", "enabled"])
+def test_download_without_head_content_length(tmp_path: Path, use_local_dir:
bool, xet_mode: str) -> None:
+ content = b"content"
+
+ def _mock_head(*, url: str, **kwargs) -> httpx.Response:
+ headers = {constants.HUGGINGFACE_HEADER_X_REPO_COMMIT: "a" * 40,
"ETag": '"etag"'}
+ if xet_mode != "no_metadata":
+ headers[constants.HUGGINGFACE_HEADER_X_XET_HASH] = "b" * 64
+ headers[constants.HUGGINGFACE_HEADER_X_XET_REFRESH_ROUTE] =
"https://huggingface.co/xet-refresh"
+ return httpx.Response(
+ 200,
+ headers=headers,
+ request=httpx.Request("HEAD", url),
+ )
+
+ @contextmanager
+ def _mock_get(*args, **kwargs):
+ yield httpx.Response(
+ 200,
+ headers={"Content-Length": str(len(content))},
+ content=content,
+ request=httpx.Request("GET",
"https://huggingface.co/user/repo/resolve/main/file.txt"),
+ )
+
+ download_kwargs = {"cache_dir": tmp_path / "cache"}
+ if use_local_dir:
+ download_kwargs["local_dir"] = tmp_path / "local"
+
+ with (
+
patch("huggingface_hub.file_download._httpx_follow_hub_redirects_with_backoff",
side_effect=_mock_head),
+ patch("huggingface_hub.file_download.http_stream_backoff",
side_effect=_mock_get) as mock_get,
+ patch("huggingface_hub.constants.HF_HUB_DISABLE_XET", xet_mode ==
"disabled"),
+ patch("huggingface_hub.utils._runtime.is_package_available",
return_value=xet_mode != "not_installed"),
+ ):
+ if xet_mode == "enabled":
+ with pytest.raises(LocalEntryNotFoundError) as exc:
+ hf_hub_download("user/repo", "file.txt", **download_kwargs)
+ assert isinstance(exc.value.__cause__, FileMetadataError)
+ mock_get.assert_not_called()
+ return
+ path = hf_hub_download("user/repo", "file.txt", **download_kwargs)
+
+ assert Path(path).read_bytes() == content
+
+
class TestDiskUsageWarning:
@pytest.fixture(scope="class", autouse=True)
def setup(self, request):
@@ -1049,6 +1095,12 @@
class TestHttpGet:
+ def
test_http_get_validates_content_length_when_expected_size_is_missing(self):
+ with pytest.raises(OSError, match="file should be of size 100 but has
size 50"):
+ self._http_get_with_mocked_responses(
+ [self._mock_response(headers={"Content-Length": "100"},
iter_bytes=iter([b"A" * 50]))]
+ )
+
def test_http_get_with_ssl_and_timeout_error(self, caplog):
def _iter_content_1() -> Iterable[bytes]:
yield b"0" * 10
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-1.30.0/tests/test_generate_cli_reference.py
new/huggingface_hub-1.31.0/tests/test_generate_cli_reference.py
--- old/huggingface_hub-1.30.0/tests/test_generate_cli_reference.py
2026-09-03 12:04:54.421204600 +0200
+++ new/huggingface_hub-1.31.0/tests/test_generate_cli_reference.py
2026-09-10 12:26:54.624192700 +0200
@@ -1,4 +1,6 @@
-from utils.generate_cli_reference import _normalize_command_aliases
+import click
+
+from utils.generate_cli_reference import _normalize_command_aliases,
get_docs_for_click
def test_normalize_nested_aliases_in_usage_lines() -> None:
@@ -39,3 +41,19 @@
assert "$ hf extensions list [OPTIONS]" in normalized
assert "$ hf extensions | ext list [OPTIONS]" not in normalized
+
+
+def test_short_help_is_not_truncated() -> None:
+ @click.group()
+ def cli() -> None: ...
+
+ @cli.command()
+ def verify() -> None:
+ """Verify checksums for a single repo revision from cache or a local
directory.
+
+ More details here.
+ """
+
+ docs = get_docs_for_click(obj=cli, ctx=click.Context(cli, info_name="hf"),
name="hf")
+
+ assert "* `verify`: Verify checksums for a single repo revision from cache
or a local directory.\n" in docs
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/tests/test_lfs.py
new/huggingface_hub-1.31.0/tests/test_lfs.py
--- old/huggingface_hub-1.30.0/tests/test_lfs.py 2026-09-03
12:04:54.422204500 +0200
+++ new/huggingface_hub-1.31.0/tests/test_lfs.py 2026-09-10
12:26:54.626192600 +0200
@@ -116,6 +116,18 @@
assert fileobj_slice.tell() == 0
assert fileobj_slice.fileobj.tell() == 100
+ def test_slice_fileobj_iter_reads_full_slice(self):
+ # Regression test: iterating a slice must yield the whole slice in 4 MB
+ # chunks. Previously __iter__ had a single `yield`, so it returned
only the
+ # first 4 MB chunk and silently dropped everything after it.
+ chunk_size = 4 * 1024 * 1024
+ content = b"x" * (chunk_size + 500) # spans more than one chunk
+ fileobj = BytesIO(content)
+ with SliceFileObj(fileobj, seek_from=0, read_limit=len(content)) as
fileobj_slice:
+ chunks = list(fileobj_slice)
+ assert len(chunks) > 1
+ assert b"".join(chunks) == content
+
def test_slice_fileobj_file(self):
self.content = b"RANDOM self.content uauabciabeubahveb" * 1024
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-1.30.0/tests/test_utils_dotenv.py
new/huggingface_hub-1.31.0/tests/test_utils_dotenv.py
--- old/huggingface_hub-1.30.0/tests/test_utils_dotenv.py 2026-09-03
12:04:54.424204600 +0200
+++ new/huggingface_hub-1.31.0/tests/test_utils_dotenv.py 2026-09-10
12:26:54.627192500 +0200
@@ -91,3 +91,94 @@
"""
environ = {"A": "one", "B": "two", "D": "four", "EMPTY": ""}
assert load_dotenv(data, environ=environ) == {"A": "1", "B": "two", "C":
"3", "EMPTY": ""}
+
+
+def test_single_quoted_values_are_literal():
+ # Single-quoted values are kept verbatim: escape sequences such as "\n"
and "\t"
+ # are NOT expanded (unlike double-quoted values).
+ data = r"""
+ NEWLINE='line1\nline2'
+ TAB='a\tb'
+ ESCAPED_QUOTE='a\"b'
+ """
+ assert load_dotenv(data) == {
+ "NEWLINE": r"line1\nline2",
+ "TAB": r"a\tb",
+ "ESCAPED_QUOTE": r"a\"b",
+ }
+ assert load_dotenv(r'DQ="line1\nline2"') == {"DQ": "line1\nline2"}
+
+
+def test_hash_in_unquoted_value_is_kept():
+ # A "#" only starts an inline comment when preceded by whitespace. A "#"
that is part of an
+ # unquoted value (e.g. in a password, token or URL fragment) must be
preserved, not truncated.
+ data = """
+ PASSWORD=p@ss#word
+ TOKEN=abc#123
+ URL=http://example.com/x#frag
+ LEADING=#notacomment
+ COMMENTED=value # actual comment
+ """
+ assert load_dotenv(data) == {
+ "PASSWORD": "p@ss#word",
+ "TOKEN": "abc#123",
+ "URL": "http://example.com/x#frag",
+ "LEADING": "#notacomment",
+ "COMMENTED": "value",
+ }
+
+
+def test_empty_value_with_inline_comment():
+ # Whitespace right after "=" also separates an inline comment: the value
is empty, not the
+ # comment text. Otherwise a comment would leak into env vars/secrets (e.g.
`hf jobs --env-file`).
+ data = """
+ EMPTY= # comment
+ EMPTY_MULTI_SPACE= # comment
+ EMPTY_NO_COMMENT=
+ LEADING=#notacomment
+ """
+ assert load_dotenv(data) == {
+ "EMPTY": "",
+ "EMPTY_MULTI_SPACE": "",
+ "EMPTY_NO_COMMENT": "",
+ "LEADING": "#notacomment",
+ }
+
+
+def test_comment_attached_to_closing_quote():
+ # After a closing quote, a "#" starts a comment even without preceding
whitespace.
+ data = """
+ DQ="value"# comment
+ SQ='value'#comment
+ SPACED="value" # comment
+ HASH_INSIDE="a#b"
+ """
+ assert load_dotenv(data) == {
+ "DQ": "value",
+ "SQ": "value",
+ "SPACED": "value",
+ "HASH_INSIDE": "a#b",
+ }
+
+
+def test_bare_key_with_inline_comment():
+ # A bare key (no "=") is resolved from the environment. A trailing comment
must not prevent the
+ # line from matching, otherwise the key is silently dropped by
`--env-file` / `--secrets-file`.
+ data = """
+ BARE # comment
+ BARE_NO_SPACE#comment
+ BARE_PLAIN
+ """
+ environ = {"BARE": "1", "BARE_NO_SPACE": "2", "BARE_PLAIN": "3"}
+ assert load_dotenv(data, environ=environ) == {"BARE": "1",
"BARE_NO_SPACE": "2", "BARE_PLAIN": "3"}
+
+
+def test_invalid_line_does_not_import_from_environ():
+ # A key followed by arbitrary text is not a valid line: it must be ignored
rather than treated
+ # as a bare key, which would pull the host value in and clobber an
explicit assignment above.
+ data = """
+ SECRET=explicit_value
+ SECRET is documented above
+ OTHER not an assignment
+ """
+ assert load_dotenv(data, environ={"SECRET": "HOST_ENV", "OTHER":
"HOST_ENV"}) == {"SECRET": "explicit_value"}