Skip to content

Commit 8934ec0

Browse files
[Download] Tolerate missing HEAD Content-Length (#4805)
* [Download] Tolerate missing HEAD Content-Length * [Download] Allow missing HEAD size when Xet is unavailable --------- Co-authored-by: Celina Hanouti <hanouticelina@gmail.com>
1 parent 9d7bbfe commit 8934ec0

3 files changed

Lines changed: 71 additions & 16 deletions

File tree

src/huggingface_hub/cli/download.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,18 @@ def _print_result(result: str | DryRunFileInfo | list[DryRunFileInfo]) -> None:
208208
if isinstance(result, DryRunFileInfo):
209209
result = [result]
210210
will_download = [r for r in result if r.will_download]
211+
total_size = (
212+
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)
213+
)
211214
out.text(
212215
f"[dry-run] Will download {len(will_download)} files"
213216
f" (out of {len(result)})"
214-
f" totalling {_format_size(sum(r.file_size for r in will_download))}."
217+
f" totalling {_format_size(total_size) if total_size is not None else 'an unknown size'}."
215218
)
216219
items = [
217220
{
218221
"file": info.filename,
219-
"size": _format_size(info.file_size) if info.will_download else "-",
222+
"size": _format_size(info.file_size) if info.will_download and info.file_size is not None else "-",
220223
}
221224
for info in sorted(result, key=lambda x: x.filename)
222225
]

src/huggingface_hub/file_download.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ class DryRunFileInfo:
181181
Args:
182182
commit_hash (`str`):
183183
The commit_hash related to the file.
184-
file_size (`int`):
185-
Size of the file. In case of an LFS file, contains the size of the actual LFS file, not the pointer.
184+
file_size (`int`, *optional*):
185+
Size of the file, if known. In case of an LFS file, contains the size of the actual LFS file, not the
186+
pointer.
186187
filename (`str`):
187188
Name of the file in the repo.
188189
is_cached (`bool`):
@@ -193,7 +194,7 @@ class DryRunFileInfo:
193194
"""
194195

195196
commit_hash: str
196-
file_size: int
197+
file_size: int | None
197198
filename: str
198199
local_path: str
199200
is_cached: bool
@@ -407,7 +408,9 @@ def http_get(
407408
resume_size = 0
408409

409410
total: int | None = _get_file_length_from_http_response(response)
410-
if total is None:
411+
if expected_size is None:
412+
expected_size = total
413+
elif total is None:
411414
# Hub serves compressible text files (e.g. vocab.json) with `Content-Encoding: gzip` and
412415
# `Transfer-Encoding: chunked`, so the response carries no `Content-Length`. Fall back to the caller's
413416
# `expected_size` (always known from the metadata HEAD on the hf_hub path) so the progress bar, and any
@@ -1178,11 +1181,10 @@ def _hf_hub_download_to_cache_dir(
11781181
if head_call_error is not None:
11791182
_raise_on_head_call_error(head_call_error, force_download, local_files_only)
11801183

1181-
# From now on, etag, commit_hash, url and size are not None.
1184+
# From now on, etag, commit_hash and url are not None.
11821185
assert etag is not None, "etag must have been retrieved from server"
11831186
assert commit_hash is not None, "commit_hash must have been retrieved from server"
11841187
assert url_to_download is not None, "file location must have been retrieved from server"
1185-
assert expected_size is not None, "expected_size must have been retrieved from server"
11861188
blob_path = os.path.join(storage_folder, "blobs", etag)
11871189
pointer_path = _get_pointer_path(storage_folder, commit_hash, relative_filename)
11881190

@@ -1384,11 +1386,10 @@ def _hf_hub_download_to_local_dir(
13841386
if head_call_error is not None:
13851387
_raise_on_head_call_error(head_call_error, force_download, local_files_only)
13861388

1387-
# From now on, etag, commit_hash, url and size are not None.
1389+
# From now on, etag, commit_hash and url are not None.
13881390
assert etag is not None, "etag must have been retrieved from server"
13891391
assert commit_hash is not None, "commit_hash must have been retrieved from server"
13901392
assert url_to_download is not None, "file location must have been retrieved from server"
1391-
assert expected_size is not None, "expected_size must have been retrieved from server"
13921393

13931394
# Local file exists => check if it's up-to-date
13941395
if not force_download and paths.file_path.is_file():
@@ -1675,7 +1676,7 @@ def _get_metadata_or_catch_error(
16751676
|
16761677
# Or the metadata is returned as
16771678
# `(url_to_download, etag, commit_hash, expected_size, xet_file_data, None)`
1678-
tuple[str, str, str, int, XetFileData | None, None]
1679+
tuple[str, str, str, int | None, XetFileData | None, None]
16791680
):
16801681
"""Get metadata for a file on the Hub, safely handling network issues.
16811682
@@ -1767,12 +1768,11 @@ def _get_metadata_or_catch_error(
17671768
"Distant resource does not have an ETag, we won't be able to reliably ensure reproducibility."
17681769
)
17691770

1770-
# Size must exist
1771+
# Xet downloads require a known size, but regular HTTP downloads can recover it from the GET response.
17711772
expected_size = metadata.size
1772-
if expected_size is None:
1773-
raise FileMetadataError("Distant resource does not have a Content-Length.")
1774-
17751773
xet_file_data = metadata.xet_file_data
1774+
if expected_size is None and xet_file_data is not None and is_xet_available():
1775+
raise FileMetadataError("Distant resource does not have a Content-Length.")
17761776

17771777
# In case of a redirect, save an extra redirect on the request.get call,
17781778
# and ensure we download the exact atomic version even if it changed

tests/test_file_download.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
from huggingface_hub import HfApi, constants
2828
from huggingface_hub._local_folder import write_download_metadata
29-
from huggingface_hub.errors import EntryNotFoundError, GatedRepoError, LocalEntryNotFoundError
29+
from huggingface_hub.errors import EntryNotFoundError, FileMetadataError, GatedRepoError, LocalEntryNotFoundError
3030
from huggingface_hub.file_download import (
3131
_CACHED_NO_EXIST,
3232
HfFileMetadata,
@@ -70,6 +70,52 @@
7070
DATASET_SAMPLE_PY_FILE = "custom_squad.py"
7171

7272

73+
@pytest.mark.parametrize("use_local_dir", [False, True])
74+
@pytest.mark.parametrize("xet_mode", ["no_metadata", "disabled", "not_installed", "enabled"])
75+
def test_download_without_head_content_length(tmp_path: Path, use_local_dir: bool, xet_mode: str) -> None:
76+
content = b"content"
77+
78+
def _mock_head(*, url: str, **kwargs) -> httpx.Response:
79+
headers = {constants.HUGGINGFACE_HEADER_X_REPO_COMMIT: "a" * 40, "ETag": '"etag"'}
80+
if xet_mode != "no_metadata":
81+
headers[constants.HUGGINGFACE_HEADER_X_XET_HASH] = "b" * 64
82+
headers[constants.HUGGINGFACE_HEADER_X_XET_REFRESH_ROUTE] = "https://huggingface.co/xet-refresh"
83+
return httpx.Response(
84+
200,
85+
headers=headers,
86+
request=httpx.Request("HEAD", url),
87+
)
88+
89+
@contextmanager
90+
def _mock_get(*args, **kwargs):
91+
yield httpx.Response(
92+
200,
93+
headers={"Content-Length": str(len(content))},
94+
content=content,
95+
request=httpx.Request("GET", "https://huggingface.co/user/repo/resolve/main/file.txt"),
96+
)
97+
98+
download_kwargs = {"cache_dir": tmp_path / "cache"}
99+
if use_local_dir:
100+
download_kwargs["local_dir"] = tmp_path / "local"
101+
102+
with (
103+
patch("huggingface_hub.file_download._httpx_follow_hub_redirects_with_backoff", side_effect=_mock_head),
104+
patch("huggingface_hub.file_download.http_stream_backoff", side_effect=_mock_get) as mock_get,
105+
patch("huggingface_hub.constants.HF_HUB_DISABLE_XET", xet_mode == "disabled"),
106+
patch("huggingface_hub.utils._runtime.is_package_available", return_value=xet_mode != "not_installed"),
107+
):
108+
if xet_mode == "enabled":
109+
with pytest.raises(LocalEntryNotFoundError) as exc:
110+
hf_hub_download("user/repo", "file.txt", **download_kwargs)
111+
assert isinstance(exc.value.__cause__, FileMetadataError)
112+
mock_get.assert_not_called()
113+
return
114+
path = hf_hub_download("user/repo", "file.txt", **download_kwargs)
115+
116+
assert Path(path).read_bytes() == content
117+
118+
73119
class TestDiskUsageWarning:
74120
@pytest.fixture(scope="class", autouse=True)
75121
def setup(self, request):
@@ -1049,6 +1095,12 @@ def test_get_pointer_path_but_invalid_relative_filename(self) -> None:
10491095

10501096

10511097
class TestHttpGet:
1098+
def test_http_get_validates_content_length_when_expected_size_is_missing(self):
1099+
with pytest.raises(OSError, match="file should be of size 100 but has size 50"):
1100+
self._http_get_with_mocked_responses(
1101+
[self._mock_response(headers={"Content-Length": "100"}, iter_bytes=iter([b"A" * 50]))]
1102+
)
1103+
10521104
def test_http_get_with_ssl_and_timeout_error(self, caplog):
10531105
def _iter_content_1() -> Iterable[bytes]:
10541106
yield b"0" * 10

0 commit comments

Comments
 (0)