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"}

Reply via email to