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-10-30 18:17:29
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-huggingface-hub (Old)
and /work/SRC/openSUSE:Factory/.python-huggingface-hub.new.1255 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-huggingface-hub"
Fri Oct 30 18:17:29 2026 rev:21 rq:1393851 version:2.4.0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-huggingface-hub/python-huggingface-hub.changes
2026-10-26 10:04:41.402508818 +0100
+++
/work/SRC/openSUSE:Factory/.python-huggingface-hub.new.1255/python-huggingface-hub.changes
2026-10-30 18:23:31.837474733 +0100
@@ -1,0 +2,14 @@
+Fri Oct 30 07:59:09 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 2.4.0:
+ * HfFileSystem can copy files into Buckets (same or
+ another Bucket, or from model/dataset/Space repos);
+ mv and rename inside Buckets now work too
+ * hf extensions list gains VERSION and LATEST columns
+ with outdated hints; update repairs extensions whose
+ Python no longer starts
+ * Webhooks watch every domain by default and use the
+ discussion domain; cache loader rejects path-traversal
+ filenames; cache verify fixes
+
+-------------------------------------------------------------------
Old:
----
huggingface_hub-2.3.0.tar.gz
New:
----
huggingface_hub-2.4.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-huggingface-hub.spec ++++++
--- /var/tmp/diff_new_pack.e7ra9n/_old 2026-10-30 18:23:33.152529784 +0100
+++ /var/tmp/diff_new_pack.e7ra9n/_new 2026-10-30 18:23:33.168530453 +0100
@@ -23,7 +23,7 @@
%endif
%{?sle15_python_module_pythons}
Name: python-huggingface-hub
-Version: 2.3.0
+Version: 2.4.0
Release: 0
Summary: Client library for interaction with the huggingface hub
License: Apache-2.0
@@ -101,7 +101,7 @@
%python_uninstall_alternative hf
%python_uninstall_alternative tiny-agents
-%files %{python_files}
+%files %python_files
%python_alternative %{_bindir}/hf
%python_alternative %{_bindir}/tiny-agents
%{python_sitelib}/huggingface_hub
++++++ huggingface_hub-2.3.0.tar.gz -> huggingface_hub-2.4.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/PKG-INFO
new/huggingface_hub-2.4.0/PKG-INFO
--- old/huggingface_hub-2.3.0/PKG-INFO 2026-10-23 10:51:50.269081600 +0200
+++ new/huggingface_hub-2.4.0/PKG-INFO 2026-10-29 16:29:50.476030000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: huggingface_hub
-Version: 2.3.0
+Version: 2.4.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.
@@ -63,7 +63,6 @@
Requires-Dist: pytest-asyncio; extra == "testing"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "testing"
Requires-Dist: pytest-mock; extra == "testing"
-Requires-Dist: urllib3<2.0; extra == "testing"
Requires-Dist: soundfile; extra == "testing"
Requires-Dist: Pillow; extra == "testing"
Requires-Dist: numpy; extra == "testing"
@@ -98,7 +97,6 @@
Requires-Dist: pytest-asyncio; extra == "all"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "all"
Requires-Dist: pytest-mock; extra == "all"
-Requires-Dist: urllib3<2.0; extra == "all"
Requires-Dist: soundfile; extra == "all"
Requires-Dist: Pillow; extra == "all"
Requires-Dist: numpy; extra == "all"
@@ -128,7 +126,6 @@
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "dev"
Requires-Dist: pytest-mock; extra == "dev"
-Requires-Dist: urllib3<2.0; extra == "dev"
Requires-Dist: soundfile; extra == "dev"
Requires-Dist: Pillow; extra == "dev"
Requires-Dist: numpy; extra == "dev"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/setup.py
new/huggingface_hub-2.4.0/setup.py
--- old/huggingface_hub-2.3.0/setup.py 2026-10-23 10:51:44.476740100 +0200
+++ new/huggingface_hub-2.4.0/setup.py 2026-10-29 16:29:41.028048800 +0100
@@ -49,29 +49,25 @@
extras["mcp"] = ["mcp>=1.9.4, <2.0.0"] # list_tools(cursor=...) only
paginates correctly from 1.9.4
-extras["testing"] = (
- extras["oauth"]
- + [
- "jedi",
- "Jinja2",
- "pytest>=8.4.2", # we need
https://github.com/pytest-dev/pytest/pull/12436
- "pytest-cov",
- "pytest-env",
- "pytest-xdist",
- "pytest-vcr", # to mock Inference
- "pytest-asyncio", # for AsyncInferenceClient
- # 16.2 clears fixture finalizers when rerunning a test whose fixture
failed at setup.
- # Without it, any flaky fixture (e.g. a 502 from hub-ci) makes the
rerun itself crash on pytest>=9.
- "pytest-rerunfailures>=16.2", # to rerun flaky tests in CI
- "pytest-mock",
- "urllib3<2.0", # VCR.py broken with urllib3 2.0 (see
https://urllib3.readthedocs.io/en/stable/v2-migration-guide.html)
- "soundfile",
- "Pillow",
- "numpy", # for embeddings
- "duckdb; python_version < '3.15'", # for datasets SQL end-to-end
tests (no 3.15 wheels yet)
- "fastapi", # To build the documentation
- ]
-)
+extras["testing"] = extras["oauth"] + [
+ "jedi",
+ "Jinja2",
+ "pytest>=8.4.2", # we need https://github.com/pytest-dev/pytest/pull/12436
+ "pytest-cov",
+ "pytest-env",
+ "pytest-xdist",
+ "pytest-vcr", # to mock Inference
+ "pytest-asyncio", # for AsyncInferenceClient
+ # 16.2 clears fixture finalizers when rerunning a test whose fixture
failed at setup.
+ # Without it, any flaky fixture (e.g. a 502 from hub-ci) makes the rerun
itself crash on pytest>=9.
+ "pytest-rerunfailures>=16.2", # to rerun flaky tests in CI
+ "pytest-mock",
+ "soundfile",
+ "Pillow",
+ "numpy", # for embeddings
+ "duckdb; python_version < '3.15'", # for datasets SQL end-to-end tests
(no 3.15 wheels yet)
+ "fastapi", # To build the documentation
+]
extras["gradio"] = [
"gradio>=5.0.0",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/__init__.py
new/huggingface_hub-2.4.0/src/huggingface_hub/__init__.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/__init__.py 2026-10-23
10:51:44.477263700 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/__init__.py 2026-10-29
16:29:41.028558700 +0100
@@ -46,7 +46,7 @@
from typing import TYPE_CHECKING
-__version__ = "2.3.0"
+__version__ = "2.4.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-2.3.0/src/huggingface_hub/cli/cache.py
new/huggingface_hub-2.4.0/src/huggingface_hub/cli/cache.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/cli/cache.py 2026-10-23
10:51:44.480729000 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/cli/cache.py 2026-10-29
16:29:41.031862700 +0100
@@ -41,7 +41,7 @@
from ..utils._parsing import parse_duration, parse_size
from ._cli_utils import RepoIdArg, RepoTypeOpt, RevisionOpt, TokenOpt,
get_hf_api, typer_factory
from ._framework import Argument, Option
-from ._output import out
+from ._output import _ascii_safe, out
cache_cli = typer_factory(help="Manage local cache directory.")
@@ -856,19 +856,20 @@
)
exit_code = 0
+ failed = _ascii_safe("❌", "[FAIL]") # stdout may not be able to encode
"❌" (e.g. redirected on Windows)
if result.mismatches:
details = "\n".join(
f" - {m['path']}: expected {m['expected']} ({m['algorithm']}),
got {m['actual']}"
for m in result.mismatches
)
- out.text(f"❌ Checksum verification failed for the following
file(s):\n{details}")
+ out.text(f"{failed} Checksum verification failed for the following
file(s):\n{details}")
exit_code = 1
if result.missing_paths:
if fail_on_missing_files:
details = "\n".join(f" - {p}" for p in result.missing_paths)
- out.text(f"❌ Missing files (present remotely, absent
locally):\n{details}")
+ out.text(f"{failed} Missing files (present remotely, absent
locally):\n{details}")
exit_code = 1
else:
out.warning(
@@ -879,7 +880,7 @@
if result.extra_paths:
if fail_on_extra_files:
details = "\n".join(f" - {p}" for p in result.extra_paths)
- out.text(f"❌ Extra files (present locally, absent
remotely):\n{details}")
+ out.text(f"{failed} Extra files (present locally, absent
remotely):\n{details}")
exit_code = 1
else:
out.warning(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/cli/extensions.py
new/huggingface_hub-2.4.0/src/huggingface_hub/cli/extensions.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/cli/extensions.py
2026-10-23 10:51:44.480729000 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/cli/extensions.py
2026-10-29 16:29:41.031862700 +0100
@@ -19,7 +19,9 @@
import re
import shutil
import subprocess
+import time
import venv
+from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from enum import Enum
@@ -30,6 +32,7 @@
import click
import httpx2
+from huggingface_hub import constants
from huggingface_hub.errors import CLIError, CLIExtensionInstallError,
ConfirmationError
from huggingface_hub.utils import get_session, logging
@@ -41,6 +44,7 @@
DEFAULT_EXTENSION_OWNER = "huggingface"
EXTENSIONS_ROOT = Path("~/.local/share/hf/extensions")
MANIFEST_FILENAME = "manifest.json"
+_LATEST_COMMIT_FILENAME = ".latest_commit"
EXTENSIONS_HELP = (
"Manage hf CLI extensions.\n\n"
"Security Warning: extensions are third-party executables or Python
packages. "
@@ -234,6 +238,7 @@
if not executable_path.is_file():
raise CLIError(f"Extension '{short_name}' is not installed.")
+ _check_extension_update(short_name)
exit_code = _execute_extension_binary(executable_path=executable_path,
args=list(ctx.args))
raise click.exceptions.Exit(code=exit_code)
@@ -241,17 +246,24 @@
@extensions_cli.command("list | ls", examples=["hf extensions list"])
def extension_list() -> None:
"""List installed extension commands."""
+ manifests = _list_installed_extensions()
+ with ThreadPoolExecutor() as executor:
+ latest_shas = list(executor.map(_get_latest_commit_sha, manifests))
rows = [
{
"command": f"hf {manifest.short_name}",
"source": str(manifest.repo_id),
"type": str(manifest.type),
+ "version": manifest.commit_sha[:7] if manifest.commit_sha else
None,
+ "latest": manifest.commit_sha == latest_sha if latest_sha is not
None else None,
"installed": manifest.installed_at.strftime("%Y-%m-%d"),
"description": manifest.description,
}
- for manifest in _list_installed_extensions()
+ for manifest, latest_sha in zip(manifests, latest_shas)
]
out.table(rows, id_key="command")
+ if outdated := [manifest.short_name for manifest, row in zip(manifests,
rows) if row["latest"] is False]:
+ out.hint(f"Run `hf extensions update` to update {',
'.join(outdated)}.")
@extensions_cli.command("search", examples=["hf extensions search"])
@@ -354,9 +366,33 @@
if executable_path is None or not executable_path.is_file():
return None
+ _check_extension_update(short_name)
return _execute_extension_binary(executable_path=executable_path,
args=list(args[1:]))
+def _check_extension_update(short_name: str) -> None:
+ """Hint at `hf extensions update` when a newer commit is available, at
most once every 24 hours per extension.
+
+ Only prints a hint: updating stays an explicit user action. Skipped
entirely if `HF_HUB_DISABLE_UPDATE_CHECK`
+ is set. Called right before running the extension, so any error is caught
and logged.
+ """
+ if constants.HF_HUB_DISABLE_UPDATE_CHECK:
+ return
+ try:
+ if _is_latest_commit_cached(short_name):
+ return
+ manifest = ExtensionManifest.load(_get_extension_dir(short_name))
+ latest_sha = _get_latest_commit_sha(manifest)
+ except Exception:
+ logger.debug(f"Error while checking for an update of extension
'{short_name}'.", exc_info=True)
+ return
+ if latest_sha is not None and latest_sha != manifest.commit_sha:
+ out.hint(
+ f"A new version of the '{short_name}' extension is available
({manifest.repo_id})."
+ f" Run `hf extensions update {short_name}` to update it."
+ )
+
+
def _auto_install_official_extension(short_name: str) -> Path | None:
"""Try to auto-install huggingface/hf-<name>. Returns executable path or
None."""
owner, repo_name = DEFAULT_EXTENSION_OWNER, f"hf-{short_name}"
@@ -403,7 +439,11 @@
out.warning(f"Could not check updates for '{short_name}'
({owner}/{repo_name}): {error}. Skipping.")
return _ExtensionUpdateStatus.SKIPPED
- if latest_sha == manifest.commit_sha:
+ # A Python extension whose venv no longer starts is reinstalled even when
up to date.
+ if latest_sha == manifest.commit_sha and (
+ manifest.type == "binary" or
_can_run(_get_venv_python(_get_extension_dir(short_name)))
+ ):
+ _save_latest_commit_sha(short_name, latest_sha)
return _ExtensionUpdateStatus.UP_TO_DATE
_install_extension(owner=owner, repo_name=repo_name,
short_name=short_name, commit_sha=latest_sha)
@@ -466,6 +506,7 @@
commit_sha=commit_sha,
)
manifest.save(extension_dir)
+ _save_latest_commit_sha(short_name, commit_sha)
installed = True
return manifest
except CLIError:
@@ -487,18 +528,56 @@
shutil.rmtree(extension_dir, ignore_errors=True)
-def _fetch_latest_commit_sha(*, owner: str, repo_name: str) -> str:
+_GIT_HEAD_REF_RE = re.compile(r"([0-9a-f]{40}) HEAD\x00")
+
+
+def _fetch_latest_commit_sha(*, owner: str, repo_name: str, timeout: float =
_EXTENSIONS_DOWNLOAD_TIMEOUT) -> str:
"""Fetch the latest commit SHA on the default branch, used to detect
available updates.
- The only REST API call left in the install and update paths. Raises on
failure: callers decide
- whether a missing SHA is fatal, and report the reason themselves.
+ Read from the git smart HTTP endpoint (the one `git ls-remote` uses),
which is not metered like the
+ REST API. Raises on failure: callers decide whether a missing SHA is
fatal, and report the reason themselves.
"""
response = _github_request(
"GET",
- f"https://api.github.com/repos/{owner}/{repo_name}/commits/HEAD",
- headers={"Accept": "application/vnd.github.sha"},
+ f"https://github.com/{owner}/{repo_name}.git/info/refs",
+ params={"service": "git-upload-pack"},
+ timeout=timeout,
)
- return response.text.strip()
+ if (match := _GIT_HEAD_REF_RE.search(response.text)) is None:
+ raise CLIError(f"Could not find the latest commit of
'{owner}/{repo_name}'.")
+ return match.group(1)
+
+
+def _get_latest_commit_sha(manifest: ExtensionManifest) -> str | None:
+ """Latest commit SHA of an installed extension, requested from GitHub at
most once every 24 hours.
+
+ Shared by `hf extensions list` and the update hint so that running them
repeatedly never hits GitHub's rate
+ limits for unauthenticated requests. Failures are cached too (as `None`):
an offline or rate-limited machine
+ backs off instead of retrying on every call. Skipped if
`HF_HUB_DISABLE_UPDATE_CHECK` is set.
+ """
+ if constants.HF_HUB_DISABLE_UPDATE_CHECK:
+ return None
+ if _is_latest_commit_cached(manifest.short_name):
+ return (_get_extension_dir(manifest.short_name) /
_LATEST_COMMIT_FILENAME).read_text() or None
+ try:
+ latest_sha = _fetch_latest_commit_sha(owner=manifest.owner,
repo_name=manifest.repo, timeout=2)
+ except Exception as error:
+ logger.debug(f"Could not fetch the latest commit of
'{manifest.repo_id}': {error}")
+ latest_sha = None
+ _save_latest_commit_sha(manifest.short_name, latest_sha)
+ return latest_sha
+
+
+def _is_latest_commit_cached(short_name: str) -> bool:
+ cache_path = _get_extension_dir(short_name) / _LATEST_COMMIT_FILENAME
+ return cache_path.is_file() and (time.time() - cache_path.stat().st_mtime)
< 24 * 3600
+
+
+def _save_latest_commit_sha(short_name: str, sha: str | None) -> None:
+ try:
+ (_get_extension_dir(short_name) /
_LATEST_COMMIT_FILENAME).write_text(sha or "")
+ except OSError as error:
+ logger.debug(f"Could not cache the latest commit of extension
'{short_name}': {error}")
def _fetch_remote_binary(*, owner: str, repo_name: str, short_name: str) ->
bytes:
@@ -521,17 +600,19 @@
def _install_python_extension(*, extension_dir: Path, owner: str, repo_name:
str, short_name: str) -> Path:
source_url = f"https://github.com/{owner}/{repo_name}/archive/HEAD.zip"
venv_dir = extension_dir / "venv"
- venv_python = _get_venv_bin_path(venv_dir, "python.exe" if os.name == "nt"
else "python")
+ venv_python = _get_venv_python(extension_dir)
uv_path = shutil.which("uv")
status = out.status()
- if not venv_python.is_file():
+ if not _can_run(venv_python):
status.update(f"Creating virtual environment in {venv_dir}")
+ shutil.rmtree(venv_dir, ignore_errors=True)
extension_dir.mkdir(parents=True, exist_ok=True)
if uv_path:
subprocess.run([uv_path, "venv", str(venv_dir)], check=True)
else:
- venv.EnvBuilder(with_pip=True).create(str(venv_dir))
+ # Symlinks like `python -m venv`: a copied Homebrew python breaks
on its next patch upgrade.
+ venv.EnvBuilder(with_pip=True, symlinks=os.name !=
"nt").create(str(venv_dir))
status.done(f"Virtual environment created in {venv_dir}")
status.update(f"Installing package from {source_url}")
@@ -619,7 +700,7 @@
def _github_request(
- method: str, url: str, *, params: dict | None = None, headers: dict | None
= None
+ method: str, url: str, *, params: dict | None = None, timeout: float =
_EXTENSIONS_DOWNLOAD_TIMEOUT
) -> httpx2.Response:
"""Perform a GitHub request.
@@ -629,16 +710,15 @@
Unauthenticated `api.github.com` calls are capped per IP address, and that
quota is shared with
everything else calling GitHub from the same network (NAT, VPN, CI
egress). Remote refs are therefore
always addressed as `HEAD`, which GitHub resolves to the repo's default
branch on
- `raw.githubusercontent.com`, on the archive endpoint and on the REST API
alike: no
+ `raw.githubusercontent.com`, on the archive endpoint and in the git refs
advertisement alike: no
`GET /repos/{owner}/{repo}` lookup is needed to discover the branch name.
"""
response = get_session().request(
method,
url,
params=params,
- headers=headers,
follow_redirects=True,
- timeout=_EXTENSIONS_DOWNLOAD_TIMEOUT,
+ timeout=timeout,
)
# Primary limits report an exhausted quota, secondary ones only ask to
back off.
if response.status_code in (403, 429) and (
@@ -710,6 +790,18 @@
return venv_dir / "bin" / executable_name
+def _get_venv_python(extension_dir: Path) -> Path:
+ return _get_venv_bin_path(extension_dir / "venv", "python.exe" if os.name
== "nt" else "python")
+
+
+def _can_run(python: Path) -> bool:
+ """Whether `python` starts: a venv's python doesn't once its base
interpreter is gone (e.g. Homebrew upgrade)."""
+ try:
+ return subprocess.run([str(python), "-c", ""],
capture_output=True).returncode == 0
+ except OSError:
+ return False
+
+
_ALLOWED_EXTENSION_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/cli/webhooks.py
new/huggingface_hub-2.4.0/src/huggingface_hub/cli/webhooks.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/cli/webhooks.py
2026-10-23 10:51:44.482729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/cli/webhooks.py
2026-10-29 16:29:41.033862800 +0100
@@ -158,7 +158,7 @@
list[WebhookDomain] | None,
Option(
"--domain",
- help="Domain to watch: 'repo' or 'discussions'. Repeatable.
Defaults to all domains.",
+ help="Domain to watch: 'repo' or 'discussion'. Repeatable.
Defaults to all domains.",
),
] = None,
secret: Annotated[
@@ -229,7 +229,7 @@
list[WebhookDomain] | None,
Option(
"--domain",
- help="New list of domains to watch: 'repo' or 'discussions'.
Repeatable.",
+ help="New list of domains to watch: 'repo' or 'discussion'.
Repeatable.",
),
] = None,
secret: Annotated[
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/constants.py
new/huggingface_hub-2.4.0/src/huggingface_hub/constants.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/constants.py 2026-10-23
10:51:44.482729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/constants.py 2026-10-29
16:29:41.033862800 +0100
@@ -153,7 +153,7 @@
DISCUSSION_STATUS: tuple[DiscussionTypeFilter, ...] =
typing.get_args(DiscussionStatusFilter)
# Webhook subscription types
-WEBHOOK_DOMAIN_T = Literal["repo", "discussions"]
+WEBHOOK_DOMAIN_T = Literal["repo", "discussion"]
# default cache
default_home = os.path.join(os.path.expanduser("~"), ".cache")
@@ -233,7 +233,7 @@
CLAUDE_SKILLS_LOCAL_PATH = Path(".claude/skills")
CLAUDE_SKILLS_GLOBAL_PATH = Path(os.environ.get("CLAUDE_CONFIG_DIR") or
"~/.claude") / "skills"
-# Set to skip the CLI update check (PyPI query + "new version available"
warning at startup).
+# Set to skip the CLI update checks (PyPI, `hf-cli` skill and installed
extensions) and their hints.
HF_HUB_DISABLE_UPDATE_CHECK =
_is_true(os.environ.get("HF_HUB_DISABLE_UPDATE_CHECK"))
# If set, log level will be set to DEBUG and all requests made to the Hub will
be logged
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/file_download.py
new/huggingface_hub-2.4.0/src/huggingface_hub/file_download.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/file_download.py
2026-10-23 10:51:44.483729100 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/file_download.py
2026-10-29 16:29:41.033862800 +0100
@@ -1577,6 +1577,7 @@
raise ValueError(
f"Invalid repo type: {repo_type}. Accepted repo types are:
{str(constants.REPO_TYPES_WITH_KERNEL)}"
)
+ _validate_relative_filename(filename)
if cache_dir is None:
cache_dir = constants.HF_HUB_CACHE
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/src/huggingface_hub/hf_api.py
new/huggingface_hub-2.4.0/src/huggingface_hub/hf_api.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/hf_api.py 2026-10-23
10:51:44.485729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/hf_api.py 2026-10-29
16:29:41.035863000 +0100
@@ -30,7 +30,7 @@
from itertools import islice
from pathlib import Path, PurePosixPath
from secrets import token_hex
-from typing import TYPE_CHECKING, Any, BinaryIO, Literal, TypeVar, cast,
overload
+from typing import TYPE_CHECKING, Any, BinaryIO, Literal, TypeVar, cast,
get_args, overload
from urllib.parse import quote
import httpcore2
@@ -508,7 +508,7 @@
watched (`list[WebhookWatchedItem]`):
List of items watched by the webhook, see [`WebhookWatchedItem`].
domains (`list[WEBHOOK_DOMAIN_T]`):
- List of domains the webhook is watching. Can be one of `["repo",
"discussions"]`.
+ List of domains the webhook is watching. Can be one of `["repo",
"discussion"]`.
secret (`str`, *optional*):
Secret of the webhook.
disabled (`bool`):
@@ -7658,7 +7658,7 @@
>>> HfApi().comment_discussion(
... repo_id="username/repo_name",
- ... discussion_num=34
+ ... discussion_num=34,
... comment=comment
... )
# DiscussionComment(id='deadbeef0000000', type='comment', ...)
@@ -7725,7 +7725,7 @@
>>> new_title = "New title, fixing a typo"
>>> HfApi().rename_discussion(
... repo_id="username/repo_name",
- ... discussion_num=34
+ ... discussion_num=34,
... new_title=new_title
... )
# DiscussionTitleChange(id='deadbeef0000000', type='title-change',
...)
@@ -7792,11 +7792,10 @@
Examples:
```python
- >>> new_title = "New title, fixing a typo"
- >>> HfApi().rename_discussion(
+ >>> HfApi().change_discussion_status(
... repo_id="username/repo_name",
- ... discussion_num=34
- ... new_title=new_title
+ ... discussion_num=34,
+ ... new_status="closed"
... )
# DiscussionStatusChange(id='deadbeef0000000',
type='status-change', ...)
@@ -10305,7 +10304,7 @@
>>> from huggingface_hub import update_collection_metadata
>>> collection = update_collection_metadata(
... collection_slug="username/iccv-2023-64f9a55bb3115b4f513ec026",
- ... title="ICCV Oct. 2023"
+ ... title="ICCV Oct. 2023",
... description="Portfolio of models, datasets, papers and demos I
presented at ICCV Oct. 2023",
... private=False,
... theme="pink",
@@ -10469,7 +10468,7 @@
>>> add_collection_item(
... collection_slug="davanstrien/climate-64f99dc2a5067f6b65531bab",
... item_id="datasets/climate_fever",
- ... item_type="dataset"
+ ... item_type="dataset",
... note="This dataset adopts the FEVER methodology that consists
of 1,535 real-world claims regarding climate-change collected on the internet."
... )
(...)
@@ -10532,7 +10531,7 @@
>>> update_collection_item(
...
collection_slug="TheBloke/recent-models-64f9a55bb3115b4f513ec026",
... item_object_id=collection.items[-1].item_object_id,
- ... note="Newly updated model!"
+ ... note="Newly updated model!",
... position=0,
... )
```
@@ -11201,7 +11200,7 @@
List of [`WebhookWatchedItem`] to be watched by the webhook.
It can be users, orgs, models, datasets, spaces or buckets.
Watched items can also be provided as plain dictionaries.
domains (`list[Literal["repo", "discussion"]]`, optional):
- List of domains to watch. It can be "repo", "discussion" or
both.
+ List of domains to watch. It can be "repo", "discussion" or
both. Defaults to both.
secret (`str`, optional):
A secret to sign the payload with.
secrets (`dict[str, str]`, optional):
@@ -11277,9 +11276,10 @@
"""
watched_dicts = [asdict(item) if isinstance(item, WebhookWatchedItem)
else item for item in watched]
- post_webhooks_json: dict = {"watched": watched_dicts}
- if domains is not None:
- post_webhooks_json["domains"] = domains
+ post_webhooks_json: dict = {
+ "watched": watched_dicts,
+ "domains": domains if domains is not None else
list(get_args(constants.WEBHOOK_DOMAIN_T)),
+ }
if secret is not None:
post_webhooks_json["secret"] = secret
if url is not None and job_id is not None:
@@ -12236,7 +12236,7 @@
```python
>>> from huggingface_hub import run_job
>>> image = "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel"
- >>> command = ["python", "-c", "import torch; print(f"This code
ran with the following GPU: {torch.cuda.get_device_name()}")"]
+ >>> command = ["python", "-c", "import torch; print(f'This code
ran with the following GPU: {torch.cuda.get_device_name()}')"]
>>> run_job(image=image, command=command, flavor="a10g-small")
```
@@ -13185,8 +13185,8 @@
```python
>>> from huggingface_hub import create_scheduled_job
>>> image = "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel"
- >>> command = ["python", "-c", "import torch; print(f"This code
ran with the following GPU: {torch.cuda.get_device_name()}")"]
- >>> create_scheduled_job(image, command, flavor="a10g-small",
schedule="@hourly")
+ >>> command = ["python", "-c", "import torch; print(f'This code
ran with the following GPU: {torch.cuda.get_device_name()}')"]
+ >>> create_scheduled_job(image=image, command=command,
flavor="a10g-small", schedule="@hourly")
```
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/hf_file_system.py
new/huggingface_hub-2.4.0/src/huggingface_hub/hf_file_system.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/hf_file_system.py
2026-10-23 10:51:44.485729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/hf_file_system.py
2026-10-29 16:29:41.036862900 +0100
@@ -1094,10 +1094,8 @@
except EntryNotFoundError:
# Path could be a file
try:
- if self.info(path, revision=revision, **kwargs)["type"] ==
"file":
- out = {path: {}}
- else:
- out = {}
+ path_info = self.info(path, revision=revision, **kwargs)
+ out = {path: path_info} if path_info["type"] == "file" else {}
except FileNotFoundError:
out = {}
else:
@@ -1116,7 +1114,7 @@
def cp_file(self, path1: str, path2: str, revision: str | None = None,
**kwargs) -> None:
"""
- Copy a file within or between repositories.
+ Copy a file within or between repositories, or into a bucket.
> [!WARNING]
> Note: When possible, use `HfApi.upload_file()` for better
performance.
@@ -1132,10 +1130,36 @@
"""
resolved_path1 = self.resolve_path(path1, revision=revision)
resolved_path2 = self.resolve_path(path2, revision=revision)
- if isinstance(resolved_path1, HfFileSystemResolvedBucketPath) or
isinstance(
- resolved_path2, HfFileSystemResolvedBucketPath
- ):
- raise NotImplementedError("Copy from/to buckets is not available
yet")
+
+ # --- Destination is a bucket ---
+ if isinstance(resolved_path2, HfFileSystemResolvedBucketPath):
+ source_info = self.info(path1, revision=revision)
+
+ if source_info["type"] == "directory":
+ return # Folders are implicit in buckets (fsspec's recursive
copy calls cp_file on them too)
+
+ if isinstance(resolved_path1, HfFileSystemResolvedBucketPath):
+ source_type, source_id = "bucket", resolved_path1.bucket_id
+ else:
+ source_type, source_id = resolved_path1.repo_type,
resolved_path1.repo_id
+ if (xet_hash := source_info.get("xet_hash")) is not None:
+ # Server-side copy by xet hash (no data transfer)
+ self._api.batch_bucket_files(
+ resolved_path2.bucket_id, copy=[(source_type, source_id,
xet_hash, resolved_path2.path)]
+ )
+ else:
+ # Non-Xet repo file: download and upload via
batch_bucket_files add
+ with self.open(path1, "rb", revision=revision) as f:
+ content = f.read()
+ self._api.batch_bucket_files(resolved_path2.bucket_id,
add=[(content, resolved_path2.path)])
+
+ self.invalidate_cache(path=resolved_path1.unresolve())
+ self.invalidate_cache(path=resolved_path2.unresolve())
+ return
+
+ # --- Source is a bucket, destination is a repository (unsupported) ---
+ if isinstance(resolved_path1, HfFileSystemResolvedBucketPath):
+ raise NotImplementedError("Copy from buckets to repositories is
not available yet")
same_repo = (
resolved_path1.repo_type == resolved_path2.repo_type and
resolved_path1.repo_id == resolved_path2.repo_id
@@ -1760,7 +1784,11 @@
def _raise_file_not_found(path: str, err: Exception | None) -> NoReturn:
msg = path
if isinstance(err, RepositoryNotFoundError):
- msg = f"{path} (repository not found)"
+ msg = (
+ f"{path} (repository not found). "
+ "If the repo is private, make sure you are authenticated "
+ "and your token has the required permissions."
+ )
elif isinstance(err, RevisionNotFoundError):
msg = f"{path} (revision not found)"
elif isinstance(err, HFValidationError):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/utils/_paths.py
new/huggingface_hub-2.4.0/src/huggingface_hub/utils/_paths.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/utils/_paths.py
2026-10-23 10:51:44.491879500 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/utils/_paths.py
2026-10-29 16:29:41.043375300 +0100
@@ -122,10 +122,10 @@
```python
>>> list(filter_repo_objects(
... [
- ... CommitOperationAdd(path_or_fileobj="/tmp/aaa.pdf",
path_in_repo="aaa.pdf")
- ... CommitOperationAdd(path_or_fileobj="/tmp/bbb.jpg",
path_in_repo="bbb.jpg")
- ... CommitOperationAdd(path_or_fileobj="/tmp/.ccc.pdf",
path_in_repo=".ccc.pdf")
- ... CommitOperationAdd(path_or_fileobj="/tmp/.ddd.png",
path_in_repo=".ddd.png")
+ ... CommitOperationAdd(path_or_fileobj="/tmp/aaa.pdf",
path_in_repo="aaa.pdf"),
+ ... CommitOperationAdd(path_or_fileobj="/tmp/bbb.jpg",
path_in_repo="bbb.jpg"),
+ ... CommitOperationAdd(path_or_fileobj="/tmp/.ccc.pdf",
path_in_repo=".ccc.pdf"),
+ ... CommitOperationAdd(path_or_fileobj="/tmp/.ddd.png",
path_in_repo=".ddd.png"),
... ],
... allow_patterns=["*.pdf"],
... ignore_patterns=[".*"],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/utils/_verification.py
new/huggingface_hub-2.4.0/src/huggingface_hub/utils/_verification.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/utils/_verification.py
2026-10-23 10:51:44.492729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/utils/_verification.py
2026-10-29 16:29:41.043863000 +0100
@@ -5,6 +5,7 @@
from .. import constants
from ..file_download import repo_folder_name
+from ._paths import DEFAULT_IGNORE_PATTERNS, filter_repo_objects
from .sha import git_hash, sha_fileobj
@@ -39,8 +40,12 @@
def collect_local_files(root: Path) -> dict[str, Path]:
"""
Return a mapping of repo-relative path -> absolute path for all files
under `root`.
+
+ Files matching `DEFAULT_IGNORE_PATTERNS` (`.git/` and the
`.cache/huggingface/` metadata that downloads to a
+ `local_dir` write) are skipped, since they can never be part of a repo.
"""
- return {p.relative_to(root).as_posix(): p for p in root.rglob("*") if
p.is_file()}
+ files = {p.relative_to(root).as_posix(): p for p in root.rglob("*") if
p.is_file()}
+ return {path: files[path] for path in filter_repo_objects(files,
ignore_patterns=DEFAULT_IGNORE_PATTERNS)}
def _resolve_commit_hash_from_cache(storage_folder: Path, revision: str |
None) -> str:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/utils/logging.py
new/huggingface_hub-2.4.0/src/huggingface_hub/utils/logging.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/utils/logging.py
2026-10-23 10:51:44.492729200 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/utils/logging.py
2026-10-29 16:29:41.043863000 +0100
@@ -87,10 +87,10 @@
Example:
```python
- >>> from huggingface_hub import get_logger
+ >>> from huggingface_hub import logging
- >>> logger = get_logger(__file__)
- >>> logger.set_verbosity_info()
+ >>> logger = logging.get_logger(__name__)
+ >>> logging.set_verbosity_info()
```
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub/utils/tqdm.py
new/huggingface_hub-2.4.0/src/huggingface_hub/utils/tqdm.py
--- old/huggingface_hub-2.3.0/src/huggingface_hub/utils/tqdm.py 2026-10-23
10:51:44.493729400 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub/utils/tqdm.py 2026-10-29
16:29:41.044863000 +0100
@@ -55,17 +55,17 @@
>>> disable_progress_bars("peft.foo")
# Check state of different groups
- >>> assert not are_progress_bars_disabled("peft"))
+ >>> assert not are_progress_bars_disabled("peft")
>>> assert not are_progress_bars_disabled("peft.something")
- >>> assert are_progress_bars_disabled("peft.foo"))
- >>> assert are_progress_bars_disabled("peft.foo.bar"))
+ >>> assert are_progress_bars_disabled("peft.foo")
+ >>> assert are_progress_bars_disabled("peft.foo.bar")
# Enable progress bars for a subgroup
>>> enable_progress_bars("peft.foo.bar")
# Check if enabling a subgroup affects the parent group
- >>> assert are_progress_bars_disabled("peft.foo"))
- >>> assert not are_progress_bars_disabled("peft.foo.bar"))
+ >>> assert are_progress_bars_disabled("peft.foo")
+ >>> assert not are_progress_bars_disabled("peft.foo.bar")
# No progress bar for `name="peft.foo"`
>>> for _ in tqdm(range(5), name="peft.foo"):
@@ -290,7 +290,7 @@
Example:
```py
>>> with tqdm_stream_file("config.json") as f:
- >>> httpx2.put(url, data=f)
+ ... httpx2.put(url, data=f)
config.json: 100%|█████████████████████████| 8.19k/8.19k [00:02<00:00,
3.72kB/s]
```
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub.egg-info/PKG-INFO
new/huggingface_hub-2.4.0/src/huggingface_hub.egg-info/PKG-INFO
--- old/huggingface_hub-2.3.0/src/huggingface_hub.egg-info/PKG-INFO
2026-10-23 10:51:50.185049300 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub.egg-info/PKG-INFO
2026-10-29 16:29:50.385028600 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: huggingface_hub
-Version: 2.3.0
+Version: 2.4.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.
@@ -63,7 +63,6 @@
Requires-Dist: pytest-asyncio; extra == "testing"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "testing"
Requires-Dist: pytest-mock; extra == "testing"
-Requires-Dist: urllib3<2.0; extra == "testing"
Requires-Dist: soundfile; extra == "testing"
Requires-Dist: Pillow; extra == "testing"
Requires-Dist: numpy; extra == "testing"
@@ -98,7 +97,6 @@
Requires-Dist: pytest-asyncio; extra == "all"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "all"
Requires-Dist: pytest-mock; extra == "all"
-Requires-Dist: urllib3<2.0; extra == "all"
Requires-Dist: soundfile; extra == "all"
Requires-Dist: Pillow; extra == "all"
Requires-Dist: numpy; extra == "all"
@@ -128,7 +126,6 @@
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: pytest-rerunfailures>=16.2; extra == "dev"
Requires-Dist: pytest-mock; extra == "dev"
-Requires-Dist: urllib3<2.0; extra == "dev"
Requires-Dist: soundfile; extra == "dev"
Requires-Dist: Pillow; extra == "dev"
Requires-Dist: numpy; extra == "dev"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/src/huggingface_hub.egg-info/requires.txt
new/huggingface_hub-2.4.0/src/huggingface_hub.egg-info/requires.txt
--- old/huggingface_hub-2.3.0/src/huggingface_hub.egg-info/requires.txt
2026-10-23 10:51:50.189984800 +0200
+++ new/huggingface_hub-2.4.0/src/huggingface_hub.egg-info/requires.txt
2026-10-29 16:29:50.390696800 +0100
@@ -27,7 +27,6 @@
pytest-asyncio
pytest-rerunfailures>=16.2
pytest-mock
-urllib3<2.0
soundfile
Pillow
numpy
@@ -59,7 +58,6 @@
pytest-asyncio
pytest-rerunfailures>=16.2
pytest-mock
-urllib3<2.0
soundfile
Pillow
numpy
@@ -117,7 +115,6 @@
pytest-asyncio
pytest-rerunfailures>=16.2
pytest-mock
-urllib3<2.0
soundfile
Pillow
numpy
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/huggingface_hub-2.3.0/tests/test_buckets_hf_file_system.py
new/huggingface_hub-2.4.0/tests/test_buckets_hf_file_system.py
--- old/huggingface_hub-2.3.0/tests/test_buckets_hf_file_system.py
2026-10-23 10:51:44.495489000 +0200
+++ new/huggingface_hub-2.4.0/tests/test_buckets_hf_file_system.py
2026-10-29 16:29:41.047031000 +0100
@@ -104,9 +104,60 @@
yield
self.api.delete_bucket(self.bucket_id)
- @pytest.mark.skip("Not implemented yet")
def test_copy_file(self):
- pass
+ self.hffs.cp_file(self.text_file, self.hf_path +
"/data/text_data_copy.txt")
+ with self.hffs.open(self.hf_path + "/data/text_data_copy.txt", "r") as
f:
+ assert f.read() == "dummy text data"
+
+ def test_move_and_rename_file(self):
+ copied_file = self.hf_path + "/data/text_data_copy.txt"
+ moved_file = self.hf_path + "/data/text_data_moved.txt"
+ renamed_file = self.hf_path + "/data/text_data_renamed.txt"
+
+ self.hffs.cp_file(self.text_file, copied_file)
+ self.hffs.mv(copied_file, moved_file)
+ assert not self.hffs.exists(copied_file)
+ assert self.hffs.exists(moved_file)
+
+ self.hffs.rename(moved_file, renamed_file)
+ assert not self.hffs.exists(moved_file)
+ with self.hffs.open(renamed_file, "r") as f:
+ assert f.read() == "dummy text data"
+
+ def test_copy_between_buckets(self):
+ other_bucket_id = self.api.create_bucket(repo_name()).bucket_id
+ try:
+ self.hffs.copy(self.hf_path + "/data",
f"buckets/{other_bucket_id}/data", recursive=True)
+ assert
self.hffs.cat(f"buckets/{other_bucket_id}/data/text_data.txt") == b"dummy text
data"
+ assert
self.hffs.cat(f"buckets/{other_bucket_id}/data/binary_data.bin") == b"dummy
binary data"
+ finally:
+ self.api.delete_bucket(other_bucket_id)
+
+ def test_copy_repo_to_bucket(self):
+ repo_id = self.api.create_repo(repo_name(), repo_type="model").repo_id
+ try:
+ self.api.upload_file(
+ path_or_fileobj=b"dummy text data",
+ path_in_repo="data/text_data.txt",
+ repo_id=repo_id,
+ repo_type="model",
+ )
+ self.hffs.cp_file(
+ f"models/{repo_id}/data/text_data.txt",
+ self.hf_path + "/data/text_data_from_repo.txt",
+ )
+ with self.hffs.open(self.hf_path +
"/data/text_data_from_repo.txt", "r") as f:
+ assert f.read() == "dummy text data"
+ finally:
+ self.api.delete_repo(repo_id, repo_type="model")
+
+ def test_copy_bucket_to_repo_is_not_supported(self):
+ repo_id = self.api.create_repo(repo_name(), repo_type="model").repo_id
+ try:
+ with pytest.raises(NotImplementedError, match="buckets to
repositories"):
+ self.hffs.cp_file(self.text_file,
f"models/{repo_id}/data/text_data_copy.txt")
+ finally:
+ self.api.delete_repo(repo_id, repo_type="model")
class TestHfFileSystemBucketLiveFollow:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/tests/test_cli.py
new/huggingface_hub-2.4.0/tests/test_cli.py
--- old/huggingface_hub-2.3.0/tests/test_cli.py 2026-10-23 10:51:44.495729200
+0200
+++ new/huggingface_hub-2.4.0/tests/test_cli.py 2026-10-29 16:29:41.047031000
+0100
@@ -5683,7 +5683,7 @@
BINARY_URL = "raw.githubusercontent.com/huggingface/hf-demo/HEAD/hf-demo"
REPO_PAGE_URL = "https://github.com/huggingface/hf-demo"
-COMMITS_URL = "https://api.github.com/repos/huggingface/hf-demo/commits/HEAD"
+REFS_URL = "https://github.com/huggingface/hf-demo.git/info/refs"
class TestExtensionsGitHubAccess:
@@ -5712,15 +5712,15 @@
],
)
@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(
+ def test_install_uses_head_refs_and_no_api_call(
self, github: _FakeGitHubSession, about: str, expected_description:
str | None
) -> None:
# Shell-script extensions ship neither manifest.json nor
pyproject.toml, so the repo's "About"
# field is their only description. It is served by github.com, off the
REST API quota.
github.responses = {
BINARY_URL: httpx2.Response(200, content=b"#!/bin/sh"),
+ REFS_URL: httpx2.Response(200, text=f"{'a' * 40}
HEAD\x00symref=HEAD:refs/heads/main\n"),
REPO_PAGE_URL: httpx2.Response(200, text=f'<meta
name="description" content="{about}">'),
- COMMITS_URL: httpx2.Response(200, text="a" * 40),
}
manifest = extensions._install_extension(owner="huggingface",
repo_name="hf-demo", short_name="demo")
@@ -5728,18 +5728,18 @@
assert manifest.commit_sha == "a" * 40
assert manifest.description == expected_description
# The default branch is addressed as `HEAD`, so no `GET
/repos/{owner}/{repo}` lookup is needed.
- assert github.api_urls == [COMMITS_URL]
+ assert github.api_urls == []
raw_prefix = "https://raw.githubusercontent.com/huggingface/hf-demo/"
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:
+ def test_install_completes_when_the_commit_lookup_is_rate_limited(self,
github: _FakeGitHubSession) -> None:
# The extension itself comes from the CDN, so only the optional
version marker is lost.
github.responses = {
BINARY_URL: httpx2.Response(200, content=b"#!/bin/sh"),
"HEAD/manifest.json": httpx2.Response(200, json={"description":
"Demo extension"}),
- "api.github.com": httpx2.Response(403,
headers={"x-ratelimit-remaining": "0"}),
+ REFS_URL: httpx2.Response(429, headers={"retry-after": "60"}),
}
manifest = extensions._install_extension(owner="huggingface",
repo_name="hf-demo", short_name="demo")
@@ -5765,7 +5765,7 @@
# A secondary limit: GitHub asks for a back-off and rides the
*primary* window's reset
# timestamp alongside it, on a quota that is not exhausted. The
back-off is what applies.
github.responses = {
- "api.github.com": httpx2.Response(
+ ".git/info/refs": httpx2.Response(
429,
headers={"x-ratelimit-remaining": "53", "x-ratelimit-reset":
"1786500000", "retry-after": "60"},
)
@@ -5777,4 +5777,4 @@
assert "Retry in 60s." in str(result.exception)
assert "It resets at" not in str(result.exception)
# Bailed out on the first extension rather than repeating the failure
for every one of them.
- assert len(github.api_urls) == 1
+ assert len(github.urls) == 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/tests/test_hf_file_system.py
new/huggingface_hub-2.4.0/tests/test_hf_file_system.py
--- old/huggingface_hub-2.3.0/tests/test_hf_file_system.py 2026-10-23
10:51:44.497729300 +0200
+++ new/huggingface_hub-2.4.0/tests/test_hf_file_system.py 2026-10-29
16:29:41.048863200 +0100
@@ -311,6 +311,9 @@
files = self.hffs.find(self.text_file, detail=False)
assert files == [self.text_file]
+ files = self.hffs.find(self.text_file, detail=True)
+ assert files == {self.text_file: self.hffs.info(self.text_file)}
+
def test_find_maxdepth(self):
text_file_depth = self.text_file_path.count("/") + 1
files = self.hffs.find(self.hf_path, detail=False,
maxdepth=text_file_depth - 1)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/tests/test_oauth.py
new/huggingface_hub-2.4.0/tests/test_oauth.py
--- old/huggingface_hub-2.3.0/tests/test_oauth.py 2026-10-23
10:51:44.498729200 +0200
+++ new/huggingface_hub-2.4.0/tests/test_oauth.py 2026-10-29
16:29:41.049863000 +0100
@@ -122,13 +122,14 @@
assert data["msg"] == "Hello, hub.js!"
assert data["oauth_info"]["access_token"] is not None
assert data["oauth_info"]["scope"] == "openid profile email"
- assert data["oauth_info"]["user_info"] == {
+ user_info = data["oauth_info"]["user_info"]
+ assert
user_info.pop("picture").startswith("https://hub-ci.huggingface.co/avatars/")
+ assert user_info == {
"sub": "62f264b9f3c90f4b6514a269",
"name": "@huggingface/hub CI bot",
"preferred_username": "hub.js",
"email_verified": True,
"email": "[email protected]",
- "picture":
"https://hub-ci.huggingface.co/avatars/934b830e9fdaa879487852f79eef7165.svg",
"profile": "https://hub-ci.huggingface.co/hub.js",
"website": "https://github.com/huggingface/hub.js",
"is_pro": None,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/huggingface_hub-2.3.0/tests/test_verification.py
new/huggingface_hub-2.4.0/tests/test_verification.py
--- old/huggingface_hub-2.3.0/tests/test_verification.py 2026-10-23
10:51:44.501729200 +0200
+++ new/huggingface_hub-2.4.0/tests/test_verification.py 2026-10-29
16:29:41.052863100 +0100
@@ -33,6 +33,18 @@
assert mapping["c.bin"].read_bytes() == b"y"
+def test_collect_local_files_skips_local_dir_metadata(tmp_path: Path) -> None:
+ # `hf download --local-dir` writes its metadata under
`.cache/huggingface/`; it is never part of the repo
+ (tmp_path / "config.json").write_text("{}")
+ (tmp_path / ".cache" / "huggingface" / "download").mkdir(parents=True)
+ (tmp_path / ".cache" / "huggingface" / ".gitignore").write_text("*")
+ (tmp_path / ".cache" / "huggingface" / "CACHEDIR.TAG").write_text("tag")
+ (tmp_path / ".cache" / "huggingface" / "download" /
"config.json.metadata").write_text("meta")
+ (tmp_path / ".cache" / "other.txt").write_text("kept")
+
+ assert sorted(collect_local_files(tmp_path)) == [".cache/other.txt",
"config.json"]
+
+
@pytest.mark.parametrize(
"algorithm,data,expected_fn",
[