Skip to content

Commit 5d8795c

Browse files
Add install_asset utility (#377)
1 parent 5604f82 commit 5d8795c

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

src/openlifu/util/assets.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Utilities for downloading and installing assets that openlifu needs."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
import tempfile
7+
from pathlib import Path
8+
9+
import requests
10+
11+
from openlifu.util.types import PathLike
12+
13+
14+
def install_asset(destination:PathLike, path_to_asset:PathLike|None, url_to_asset:str|None) -> None:
15+
"""Install a file to a location if it isn't already there.
16+
17+
Downloads if a `url_to_asset` is provided, and copies if a local `path_to_asset` is provided.
18+
Does nothing if the `destination` already exists.
19+
20+
Args:
21+
destination: The path where the asset should end up. If this already exists then the function will do nothing.
22+
path_to_asset: Local filepath; if provided then the asset will be copied from here to `destination`.
23+
Required if url_to_asset is not provided.
24+
url_to_asset: Web URL to the asset; if provided then the asset will be downloaded and saved to `destination`.
25+
Required if path_to_asset is not provided.
26+
"""
27+
destination = Path(destination)
28+
29+
if destination.exists():
30+
return
31+
32+
destination.parent.mkdir(parents=True, exist_ok=True)
33+
34+
if path_to_asset is not None:
35+
path_to_asset = Path(path_to_asset)
36+
shutil.copy2(path_to_asset, destination)
37+
elif url_to_asset is not None:
38+
temp_file_path = None
39+
try:
40+
response = requests.get(url_to_asset, stream=True, timeout=(10, 300))
41+
response.raise_for_status()
42+
with tempfile.NamedTemporaryFile(mode='wb', dir=destination.parent, delete=False) as f:
43+
temp_file_path = f.name
44+
for chunk in response.iter_content(chunk_size=8192):
45+
if chunk:
46+
f.write(chunk)
47+
shutil.move(temp_file_path, destination)
48+
finally:
49+
if temp_file_path is not None and Path(temp_file_path).exists():
50+
Path(temp_file_path).unlink()
51+
else:
52+
raise ValueError("Either path_to_asset or url_to_asset must be provided.")

tests/test_assets.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import shutil
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
import requests
8+
9+
from openlifu.util.assets import install_asset
10+
11+
12+
def test_destination_already_exists(tmp_path, mocker):
13+
"""Test that nothing happens if the destination file already exists."""
14+
destination = tmp_path / "asset.dat"
15+
destination.write_text("original content")
16+
17+
spy_copy = mocker.spy(shutil, "copy2")
18+
spy_get = mocker.spy(requests, "get")
19+
20+
install_asset(destination, path_to_asset="dummy/path", url_to_asset="http://dummy.url")
21+
22+
assert spy_copy.call_count == 0
23+
assert spy_get.call_count == 0
24+
assert destination.read_text() == "original content"
25+
26+
def test_local_copy_succeeds(tmp_path):
27+
"""Test that a local file is copied correctly."""
28+
source_file = tmp_path / "source.txt"
29+
source_file.write_text("local asset data")
30+
destination = tmp_path / "installed" / "asset.txt"
31+
32+
install_asset(destination, path_to_asset=source_file, url_to_asset=None)
33+
34+
assert destination.exists()
35+
assert destination.read_text() == "local asset data"
36+
37+
def test_download_succeeds(tmp_path, mocker):
38+
"""Test a successful asset download and installation."""
39+
url = "http://example.com/asset.zip"
40+
destination = tmp_path / "asset.zip"
41+
fake_content = b"\x01\x02\x03\x04\x05"
42+
43+
mock_response = MagicMock()
44+
mock_response.iter_content.return_value = [fake_content]
45+
# Make raise_for_status do nothing
46+
mock_response.raise_for_status.return_value = None
47+
48+
mock_get = mocker.patch("requests.get", return_value=mock_response)
49+
50+
install_asset(destination, path_to_asset=None, url_to_asset=url)
51+
52+
mock_get.assert_called_once_with(url, stream=True, timeout=(10, 300))
53+
assert destination.exists()
54+
assert destination.read_bytes() == fake_content
55+
56+
def test_download_fails_on_http_error(tmp_path, mocker):
57+
"""Test that an HTTP error is raised and no files are left behind."""
58+
url = "http://example.com/notfound.zip"
59+
destination = tmp_path / "assets" / "notfound.zip"
60+
61+
mock_response = MagicMock()
62+
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Not Found")
63+
64+
mocker.patch("requests.get", return_value=mock_response)
65+
66+
with pytest.raises(requests.exceptions.HTTPError):
67+
install_asset(destination, path_to_asset=None, url_to_asset=url)
68+
69+
# Assert that the parent directory might exist, but is empty
70+
assert not destination.exists()
71+
assert not any(destination.parent.iterdir())
72+
73+
def test_download_cleans_up_on_interruption(tmp_path, mocker):
74+
"""Test that a mid-download interruption cleans up the temporary file."""
75+
url = "http://example.com/largefile.zip"
76+
destination = tmp_path / "assets" / "largefile.zip"
77+
expected_error_message = "Network connection broken"
78+
79+
mock_response = MagicMock()
80+
# Simulate an error after the first chunk is read
81+
mock_response.iter_content.side_effect = OSError(expected_error_message)
82+
mock_response.raise_for_status.return_value = None
83+
84+
mocker.patch("requests.get", return_value=mock_response)
85+
86+
with pytest.raises(IOError, match=expected_error_message):
87+
install_asset(destination, path_to_asset=None, url_to_asset=url)
88+
89+
assert not destination.exists()
90+
assert not any(destination.parent.iterdir())
91+
92+
def test_raises_error_if_no_source_provided(tmp_path):
93+
"""Test that a ValueError is raised if no source is given."""
94+
destination = tmp_path / "asset.dat"
95+
with pytest.raises(ValueError, match="Either path_to_asset or url_to_asset must be provided."):
96+
install_asset(destination, path_to_asset=None, url_to_asset=None)

0 commit comments

Comments
 (0)