Skip to content

Commit be0cc45

Browse files
committed
Send default User-Agent and accept headers in save_large_file
save_large_file only accepted a user_agent parameter, so callers could not set other headers, and unlike stream_file it did not send the Wikimedia-compliant default User-Agent for upload.wikimedia.org. Accept arbitrary headers, merge them with the existing user_agent, and fall back to the default User-Agent for Wikimedia upload URLs when none is set. Closes #360
1 parent 9a25ed3 commit be0cc45

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Index ePub documents for full-text search, with a new `get_epub_index_data` helper and automatic indexing of ePub items, like PDFs already are (#333)
1313
- Send a Wikimedia-compliant default `User-Agent` in `stream_file` when downloading from `upload.wikimedia.org` (#214)
14+
- `save_large_file` accepts arbitrary HTTP headers and sends the default Wikimedia-compliant `User-Agent` for `upload.wikimedia.org`, like `stream_file` (#360)
1415
- Add fuzzy rule to rewrite URLs of lesbases.anct.gouv.fr (#334)
1516
- Report progress from `save_large_file` through a new `on_progress` callback and periodic logging, and stream the download to a `.part` file that is only moved into place once complete (#348)
1617
- New `get_pdf_title` and `get_epub_title` helpers to get a document's title from its own metadata without extracting its full text content, cheaper than `get_pdf_index_data`/`get_epub_index_data` when only the title is needed (openzim/warc2zim#462)

‎src/zimscraperlib/download.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,13 @@ def _log_download_progress(url: str, downloaded: int, total_size: int | None) ->
165165
logger.info(f"Downloaded {_format_size(downloaded)} of {url}")
166166

167167

168-
def save_large_file(
168+
def save_large_file( # noqa: PLR0917
169169
url: str,
170170
fpath: pathlib.Path,
171171
retries: int = 5,
172172
user_agent: str | None = None,
173173
on_progress: Callable[[int, int | None], None] | None = None,
174+
headers: dict[str, str] | None = None,
174175
) -> None:
175176
"""Download a binary file from its URL, streaming it with resume support.
176177
@@ -189,18 +190,29 @@ def save_large_file(
189190
url: URL of the file to download
190191
fpath: path where the downloaded file is written
191192
retries: number of consecutive attempts without progress tolerated
192-
user_agent: User-Agent header to send with the requests
193+
user_agent: User-Agent header to send with the requests, takes precedence
194+
over a User-Agent entry in `headers`
193195
on_progress: optional callback called with the bytes downloaded so far and
194196
the total size, when known
197+
headers: additional HTTP headers to send with the requests
195198
"""
196-
headers: dict[str, str] = {"User-Agent": user_agent} if user_agent else {}
199+
base_headers: dict[str, str] = dict(headers or {})
200+
if user_agent:
201+
base_headers = {
202+
name: value
203+
for name, value in base_headers.items()
204+
if name.lower() != "user-agent"
205+
}
206+
base_headers["User-Agent"] = user_agent
207+
if is_wikimedia_upload_url(url) and not has_user_agent_header(base_headers):
208+
base_headers["User-Agent"] = DEFAULT_USER_AGENT
197209
attempts_without_progress = 0
198210
tmp_fpath = fpath.with_name(fpath.name + ".part")
199211

200212
with requests.Session() as session:
201213
while True:
202214
resume_from = tmp_fpath.stat().st_size if tmp_fpath.exists() else 0
203-
request_headers = dict(headers)
215+
request_headers = dict(base_headers)
204216
if resume_from:
205217
request_headers["Range"] = f"bytes={resume_from}-"
206218
bytes_written = 0

‎tests/download/test_download.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,96 @@ def test_save_large_file_without_user_agent(
376376
assert "User-Agent" not in kwargs["headers"]
377377

378378

379+
def test_save_large_file_default_user_agent_for_wikimedia(
380+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
381+
) -> None:
382+
get_mock = MagicMock(return_value=FakeResponse(200, [b"data"]))
383+
monkeypatch.setattr(requests.Session, "get", get_mock)
384+
385+
fpath = tmp_path / "out.zim"
386+
save_large_file(
387+
"https://upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg",
388+
fpath,
389+
)
390+
391+
_args, kwargs = get_mock.call_args
392+
assert kwargs["headers"]["User-Agent"] == DEFAULT_USER_AGENT
393+
394+
395+
def test_save_large_file_accepts_arbitrary_headers(
396+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
397+
) -> None:
398+
get_mock = MagicMock(return_value=FakeResponse(200, [b"data"]))
399+
monkeypatch.setattr(requests.Session, "get", get_mock)
400+
401+
fpath = tmp_path / "out.zim"
402+
save_large_file(
403+
"https://example.com/file.zim", fpath, headers={"Accept": "image/png"}
404+
)
405+
406+
_args, kwargs = get_mock.call_args
407+
assert kwargs["headers"]["Accept"] == "image/png"
408+
assert "User-Agent" not in kwargs["headers"]
409+
410+
411+
def test_save_large_file_user_agent_overrides_header(
412+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
413+
) -> None:
414+
get_mock = MagicMock(return_value=FakeResponse(200, [b"data"]))
415+
monkeypatch.setattr(requests.Session, "get", get_mock)
416+
417+
fpath = tmp_path / "out.zim"
418+
save_large_file(
419+
"https://example.com/file.zim",
420+
fpath,
421+
user_agent="custom-agent",
422+
headers={"user-agent": "header-agent", "Accept": "image/png"},
423+
)
424+
425+
_args, kwargs = get_mock.call_args
426+
assert kwargs["headers"] == {
427+
"Accept": "image/png",
428+
"User-Agent": "custom-agent",
429+
}
430+
431+
432+
def test_save_large_file_keeps_header_user_agent_for_wikimedia(
433+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
434+
) -> None:
435+
get_mock = MagicMock(return_value=FakeResponse(200, [b"data"]))
436+
monkeypatch.setattr(requests.Session, "get", get_mock)
437+
438+
fpath = tmp_path / "out.zim"
439+
save_large_file(
440+
"https://upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg",
441+
fpath,
442+
headers={"User-Agent": "header-agent"},
443+
)
444+
445+
_args, kwargs = get_mock.call_args
446+
assert kwargs["headers"]["User-Agent"] == "header-agent"
447+
448+
449+
def test_save_large_file_default_user_agent_for_wikimedia_with_other_headers(
450+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
451+
) -> None:
452+
get_mock = MagicMock(return_value=FakeResponse(200, [b"data"]))
453+
monkeypatch.setattr(requests.Session, "get", get_mock)
454+
455+
fpath = tmp_path / "out.zim"
456+
save_large_file(
457+
"https://upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg",
458+
fpath,
459+
headers={"Accept": "image/png"},
460+
)
461+
462+
_args, kwargs = get_mock.call_args
463+
assert kwargs["headers"] == {
464+
"Accept": "image/png",
465+
"User-Agent": DEFAULT_USER_AGENT,
466+
}
467+
468+
379469
def test_save_large_file_resumes_after_connection_error(
380470
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
381471
) -> None:

0 commit comments

Comments
 (0)