Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 54 additions & 19 deletions src/diffusers/pipelines/pipeline_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
read_dduf_file,
snapshot_download,
)
from huggingface_hub.utils import HfHubHTTPError, OfflineModeIsEnabled, validate_hf_hub_args
from huggingface_hub.utils import (
HfHubHTTPError,
LocalEntryNotFoundError,
OfflineModeIsEnabled,
validate_hf_hub_args,
)
from packaging import version
from tqdm.auto import tqdm
from typing_extensions import Self
Expand Down Expand Up @@ -1657,10 +1662,37 @@ def download(cls, pretrained_model_name, **kwargs) -> str | os.PathLike:
force_download=force_download,
token=token,
)
filenames = {sibling.rfilename for sibling in info.siblings}
else:
# We are offline (either the user asked for `local_files_only` or the `model_info` call above
# failed), so the repo's file listing needed to compute the allow/ignore patterns below cannot be
# fetched. Source the listing from the cached snapshot instead: it holds exactly the (already
# filtered) files a previous online call downloaded, so both code paths compute the same patterns
# and `snapshot_download` validates the cached snapshot against them (catching e.g. a download
# that was interrupted midway). If the config isn't cached, skip straight to `snapshot_download`
# below, which raises the appropriate offline not-found error.
try:
config_file = hf_hub_download(
pretrained_model_name,
cls.config_name,
cache_dir=cache_dir,
revision=revision,
token=token,
local_files_only=True,
)
except LocalEntryNotFoundError:
config_file = None

if config_file is not None:
snapshot_folder = Path(config_file).parent
if local_files_only:
filenames = {
f.relative_to(snapshot_folder).as_posix() for f in snapshot_folder.rglob("*") if f.is_file()
}
Comment on lines +1674 to +1691

@Wauplin Wauplin Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requesting to download a single file to then use Path(...).parent is IMO a hacky way of getting the snapshot_folder. (hence why it needs 7 lines of explanation in comments)

to avoid that I've opened huggingface/huggingface_hub#4500 to expose the snapshot_folder in the error in case of IncompleteSnapshotError. I think this is the most explicit way to do things. In diffusers the code will look like this:

try:
    snapshot_path = snapshot_download(...)
except IncompleteSnapshotError as error:
    incomplete_snapshot_path = error.snapshot_path

making it explicit for anyone reading the code that the folder is incomplete and that we knowingly ignored it

(even though, once again, I do think the best way to tackle this is to provide the correct allow_pattern/ignore_pattern filters in the first place. I still don't understand why it's not the solution we are trying to have in this PR. If there is a rational behind not trying to fix the root cause I genuinely believe it should at the very least be mentioned/documented somewhere)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for offering further thoughts. Post-mortem:

The reason we can't write the patterns down without any file listing: they're data-dependent. allow_patterns are largely concrete filenames chosen by variant_compatible_siblings (variant / sharded / safetensors-vs-bin resolution), and _get_ignore_patterns picks formats based on what actually exists in the repo.

For the online path, that listing comes from model_info().siblings. For the offline path, the only faithful source is the cached snapshot itself, which holds exactly what the same computation downloaded when online — so both modes compute the same patterns. We also test it here:

def test_local_files_only_uses_same_snapshot_download_patterns(self):

More precisely, the offline path falls through into the same pattern-computation code as online, fed with a filenames' set that came from the cache instead of model_info().siblings. Before this PR, the offline path skipped all of this (patterns stayed None).

Regarding hf_hub_download(config_file), the online branch makes the identical call because model_index.json's content dictates the computation (component folders, _ignore_files, custom pipeline):

if not local_files_only:
config_file = hf_hub_download(
pretrained_model_name,
cls.config_name,
cache_dir=cache_dir,
revision=revision,
proxies=proxies,
force_download=force_download,
token=token,
)

(All of that is used for computing the patterns)

For the offline path, we are also reusing its parent as the snapshot dir, matching the pre-existing snapshot_folder = Path(config_file).parent on main.

huggingface/huggingface_hub#4500 is cool, thanks! snapshot_path on the error does locate the folder correctly. But the error only fires from snapshot_download.We need the folder before calling snapshot_download (its listing is the input to the pattern computation). Using it would mean a deliberate no-patterns call that fails on every offline diffusers load just to catch the error.

Maybe there's a way to refactor this but that's for another PR.

@Wauplin Wauplin Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed response! That makes sense then. So if I understand correctly what's missing before-hand is the list of files in the repo when in offline mode, right? Luckily we now have this information in cache -hence why we are able to tell if a folder is incomplete-. We could definitely add a method to retrieve that cached list to that you can:

  1. make a repo info call to get repo.siblings
  2. if that fails, get repo.siblings from the cache (requires a method from huggingface_hub)
  3. and then compute the allow/ignore patterns from the list of files, no matter if you are in online or offline mode

Would that be interesting for you?

Using it would mean a deliberate no-patterns call that fails on every offline diffusers load just to catch the error.

Yes that's what I had in mind. I feel that it's less hacky than a deliberate hf_hub_download call on the index file, just to do a Path(index file).parent afterwards.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opened huggingface/huggingface_hub#4513 to add a get_cached_repo_tree method useful when repo.siblings or list_repo_tree fails (in offline mode). A priori, this is the cleanest way to determine the patterns down the line (with variants, etc.) without raising an error and without skipping the "incomplete folder check". Should be shipped today or tomorrow with the rest.


config_dict = cls._dict_from_json_file(config_file)
ignore_filenames = config_dict.pop("_ignore_files", [])

filenames = {sibling.rfilename for sibling in info.siblings}
if variant is not None and _check_legacy_sharding_variant_format(filenames=filenames, variant=variant):
warn_msg = (
f"Warning: The repository contains sharded checkpoints for variant '{variant}' maybe in a deprecated format. "
Expand All @@ -1675,9 +1707,11 @@ def download(cls, pretrained_model_name, **kwargs) -> str | os.PathLike:
logger.warning(warn_msg)

filenames = set(filenames) - set(ignore_filenames)
if revision in DEPRECATED_REVISION_ARGS and version.parse(
version.parse(__version__).base_version
) >= version.parse("0.22.0"):
if (
not local_files_only
and revision in DEPRECATED_REVISION_ARGS
and version.parse(version.parse(__version__).base_version) >= version.parse("0.22.0")
):
warn_deprecated_model_variant(pretrained_model_name, token, variant, revision, filenames)

custom_components, folder_names = _get_custom_components_and_folders(
Expand Down Expand Up @@ -1762,19 +1796,20 @@ def download(cls, pretrained_model_name, **kwargs) -> str | os.PathLike:

# Don't download index files of forbidden patterns either
ignore_patterns = ignore_patterns + [f"{i}.index.*json" for i in ignore_patterns]
re_ignore_pattern = [re.compile(fnmatch.translate(p)) for p in ignore_patterns]
re_allow_pattern = [re.compile(fnmatch.translate(p)) for p in allow_patterns]

expected_files = [f for f in filenames if not any(p.match(f) for p in re_ignore_pattern)]
expected_files = [f for f in expected_files if any(p.match(f) for p in re_allow_pattern)]
if not local_files_only:
re_ignore_pattern = [re.compile(fnmatch.translate(p)) for p in ignore_patterns]
re_allow_pattern = [re.compile(fnmatch.translate(p)) for p in allow_patterns]

snapshot_folder = Path(config_file).parent
pipeline_is_cached = all((snapshot_folder / f).is_file() for f in expected_files)
expected_files = [f for f in filenames if not any(p.match(f) for p in re_ignore_pattern)]
expected_files = [f for f in expected_files if any(p.match(f) for p in re_allow_pattern)]

pipeline_is_cached = all((snapshot_folder / f).is_file() for f in expected_files)

if pipeline_is_cached and not force_download:
# if the pipeline is cached, we can directly return it
# else call snapshot_download
return snapshot_folder
if pipeline_is_cached and not force_download:
# if the pipeline is cached, we can directly return it
# else call snapshot_download
return snapshot_folder

user_agent = {"pipeline_class": cls.__name__}
if custom_pipeline is not None and not custom_pipeline.endswith(".py"):
Expand Down Expand Up @@ -1817,7 +1852,7 @@ def download(cls, pretrained_model_name, **kwargs) -> str | os.PathLike:

return cached_folder

except FileNotFoundError:
except FileNotFoundError as e:
# Means we tried to load pipeline with `local_files_only=True` but the files have not been found in local cache.
# This can happen in two cases:
# 1. If the user passed `local_files_only=True` => we raise the error directly
Expand All @@ -1828,9 +1863,9 @@ def download(cls, pretrained_model_name, **kwargs) -> str | os.PathLike:
else:
# 2. we forced `local_files_only=True` when `model_info` failed
raise EnvironmentError(
f"Cannot load model {pretrained_model_name}: model is not cached locally and an error occurred"
" while trying to fetch metadata from the Hub. Please check out the root cause in the stacktrace"
" above."
f"Cannot load model {pretrained_model_name}: the model is not fully cached locally ({e}) and an"
" error occurred while trying to fetch metadata from the Hub. Please check out the root cause in"
" the stacktrace above."
) from model_info_call_error

@classmethod
Expand Down
6 changes: 4 additions & 2 deletions tests/models/test_modeling_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,11 @@ def test_local_files_only_with_sharded_checkpoint(self):
repo_id, subfolder="transformer", cache_dir=tmpdir, local_files_only=True
)

# Verify error mentions the missing shard
# Verify error mentions the missing shard. `huggingface_hub>=1.22.0` reports it via
# `IncompleteSnapshotError` (repo-relative path), older versions via diffusers' own check on the
# cached folder (absolute path), so match on the shard's filename.
error_msg = str(context.value)
assert cached_shard_file in error_msg or "required according to the checkpoint index" in error_msg, (
assert os.path.basename(cached_shard_file) in error_msg, (
f"Expected error about missing shard, got: {error_msg}"
)

Expand Down
46 changes: 46 additions & 0 deletions tests/pipelines/test_pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import unittest
import unittest.mock as mock
import warnings
from pathlib import Path

import numpy as np
import PIL.Image
Expand Down Expand Up @@ -504,6 +505,51 @@ def test_local_files_only_are_used_when_no_internet(self):
if p1.data.ne(p2.data).sum() > 0:
assert False, "Parameters not the same!"

def test_local_files_only_uses_same_snapshot_download_patterns(self):
# diffusers downloads a filtered subset of a repo (skipping e.g. `.gitattributes`). Newer
# `huggingface_hub` versions validate that a cached snapshot contains every file matching the
# requested patterns under `local_files_only=True`, so an offline
# `snapshot_download(allow_patterns=None)` would wrongly expect the whole repo. The offline path
# must compute the same patterns as the online one (from the cached snapshot instead of
# `model_info`). See https://github.com/huggingface/diffusers/issues/14117
with tempfile.TemporaryDirectory() as tmpdirname:
with mock.patch(
"diffusers.pipelines.pipeline_utils.snapshot_download", side_effect=snapshot_download
) as mock_snapshot_download:
cached_folder = DiffusionPipeline.download(
"hf-internal-testing/tiny-stable-diffusion-torch", cache_dir=tmpdirname
)
online_kwargs = mock_snapshot_download.call_args.kwargs

offline_folder = DiffusionPipeline.download(
"hf-internal-testing/tiny-stable-diffusion-torch",
cache_dir=tmpdirname,
local_files_only=True,
)
offline_kwargs = mock_snapshot_download.call_args.kwargs

assert os.path.samefile(offline_folder, cached_folder)
assert set(offline_kwargs["allow_patterns"]) == set(online_kwargs["allow_patterns"])
assert set(offline_kwargs["ignore_patterns"]) == set(online_kwargs["ignore_patterns"])

@require_hf_hub_version_greater("1.21.0")
def test_local_files_only_raises_for_snapshot_with_missing_weights(self):
# An interrupted download leaves a cached snapshot without some weights; loading it offline must
# surface `huggingface_hub`'s incomplete-snapshot error instead of failing later at model load time.
with tempfile.TemporaryDirectory() as tmpdirname:
cached_folder = DiffusionPipeline.download(
"hf-internal-testing/tiny-stable-diffusion-torch", cache_dir=tmpdirname
)
for weights_file in Path(cached_folder).glob("unet/diffusion_pytorch_model*"):
weights_file.unlink()

with self.assertRaisesRegex(OSError, "incomplete"):
DiffusionPipeline.download(
"hf-internal-testing/tiny-stable-diffusion-torch",
cache_dir=tmpdirname,
local_files_only=True,
)

def test_download_from_variant_folder(self):
for use_safetensors in [False, True]:
other_format = ".bin" if use_safetensors else ".safetensors"
Expand Down
Loading