Skip to content

Commit 89935c8

Browse files
authored
Merge pull request #61 from openforcefield/fetch-by-doi2
Implement fetching by doi and custom hashes
2 parents 1d14b26 + d5eb021 commit 89935c8

3 files changed

Lines changed: 191 additions & 28 deletions

File tree

openff/nagl_models/_dynamic_fetch.py

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import functools
22
import hashlib
33
import json
4+
import re
45
import pathlib
56
import urllib.request
6-
77
import platformdirs
88
from packaging.version import Version
99

@@ -19,19 +19,73 @@
1919

2020
CACHE_DIR = platformdirs.user_cache_path() / "OPENFF_NAGL_MODELS"
2121

22+
23+
class HashComparisonFailedException(Exception):
24+
"""Exception raised when a NAGL file being loaded fails a comparison to a known or user-provided hash."""
25+
26+
27+
class UnableToParseDOIException(Exception):
28+
"""Exception raised when a Zenodo DOI is unable to be parsed according to the expected pattern."""
29+
30+
2231
def get_release_metadata() -> list[dict]:
2332
return json.loads(urllib.request.urlopen(RELEASES_URL).read().decode("utf-8"))
2433

2534

2635
@functools.lru_cache()
27-
def get_model(filename: str) -> str:
28-
"""Return the path of a model as cached on disk, downloading if necessary."""
36+
def get_model(
37+
filename: str,
38+
doi: None | str = None,
39+
file_hash: None | str = None,
40+
) -> str:
41+
"""
42+
Return the path of a model as cached on disk, downloading if necessary. The lookup order of this implementation is:
43+
1. Try to retrieve the file from the local cache
44+
2. Try to fetch the file from a release of https://github.com/openforcefield/openff-nagl-models
45+
3. Try to fetch the file from the DOI, if provided
46+
47+
This method will raise an HashComparisonFailedException as soon as a hash mismatch is encountered. So if
48+
there's a file with a matching name but a non-matching hash in the local cache, an exception will be raised
49+
immediately, even if a file with a matching name that WOULD satisfy the hash check exists in release
50+
metadata or at a provided Zenodo DOI.
51+
52+
Parameters
53+
----------
54+
filename
55+
The name of the file to search for.
56+
doi
57+
The Zenodo DOI to use as a backup location for fetching the model file if it's not found in the local cache
58+
or in the
59+
[release metadata of an openff-nagl-models release](https://github.com/openforcefield/openff-nagl-models/releases)
60+
on GitHub. For example: "10.5072/zenodo.278300"
61+
file_hash
62+
The sha256 hash of the model file to verify the correct contents. Hash checks are automatically performed
63+
on some OpenFF-released NAGL models. But if the model isn't released by OpenFF and this argument is
64+
not provided or has a value of `None`, then no hash check is performed. Raises HashComparisonFailedException
65+
if unsuccessful. If a user provides a hash value here that disagrees with the known hash for the same file
66+
name, the user-provided hash takes precedence.
67+
68+
Returns
69+
-------
70+
str
71+
The path to the file if it was found. If the file wasn't found then a FileNotFoundError is rasied.
72+
73+
Raises
74+
------
75+
HashComparisonFailedException
76+
FileNotFoundError
77+
"""
78+
2979
pathlib.Path(CACHE_DIR).mkdir(exist_ok=True)
3080

3181
cached_path = CACHE_DIR / filename
3282

83+
if file_hash is None and filename in KNOWN_HASHES:
84+
file_hash = KNOWN_HASHES[filename]
85+
3386
if cached_path.exists():
34-
assert _get_sha256(cached_path) == KNOWN_HASHES[filename]
87+
if file_hash:
88+
assert_hash_equal(cached_path, file_hash)
3589

3690
return cached_path.as_posix()
3791

@@ -47,25 +101,63 @@ def get_model(filename: str) -> str:
47101
release = releases[version]
48102
for file in release["assets"]:
49103
if file["name"] == filename:
50-
path_to_file, _ = urllib.request.urlretrieve(
51-
url=file["browser_download_url"],
52-
filename=cached_path.as_posix(),
53-
)
54-
55-
assert cached_path.exists()
56-
assert path_to_file == cached_path.as_posix()
57-
58-
assert _get_sha256(cached_path) == KNOWN_HASHES[filename], (
59-
f"Hash mismatch for {filename}"
104+
return _download_and_verify_file(
105+
file["browser_download_url"], cached_path, file_hash
60106
)
61107

62-
return cached_path.as_posix()
108+
if doi:
109+
try:
110+
match = re.search(r"10\.(5072|5281)/zenodo\.([0-9]+)", doi)
111+
if not match:
112+
raise IndexError
113+
prefix, zenodo_id = match.groups()
114+
except (IndexError, AttributeError):
115+
raise UnableToParseDOIException(
116+
f"Unable to parse Zenodo DOI {doi}. DOI values are expected to look "
117+
f"like '10.5281/zenodo.278300' (production) or '10.5072/zenodo.278300' (sandbox)"
118+
)
119+
120+
if prefix == "5072":
121+
file_url = (
122+
f"https://sandbox.zenodo.org/api/records/{zenodo_id}/files/{filename}"
123+
)
124+
else:
125+
file_url = f"https://zenodo.org/api/records/{zenodo_id}/files/{filename}"
126+
127+
try:
128+
return _download_and_verify_file(file_url, cached_path, file_hash)
129+
except urllib.error.HTTPError:
130+
raise FileNotFoundError(f"No file at {file_url}")
63131

64132
raise FileNotFoundError(
65133
f"Could not find asset with name '{filename}' in any release"
66134
)
67135

68136

137+
def assert_hash_equal(cached_path, expected_hash):
138+
actual_hash = _get_sha256(cached_path)
139+
if actual_hash != expected_hash:
140+
raise HashComparisonFailedException(
141+
f"NAGL model file hash check failed. Expected hash is "
142+
f"{expected_hash} but actual hash is {actual_hash}"
143+
)
144+
145+
146+
def _download_and_verify_file(
147+
url: str, cached_path: pathlib.Path, file_hash: None | str = None
148+
) -> str:
149+
"""Download a file from URL to cached_path and optionally verify its hash."""
150+
path_to_file, _ = urllib.request.urlretrieve(url, filename=cached_path.as_posix())
151+
152+
assert cached_path.exists()
153+
assert path_to_file == cached_path.as_posix()
154+
155+
if file_hash:
156+
assert_hash_equal(cached_path, file_hash)
157+
158+
return cached_path.as_posix()
159+
160+
69161
def _get_sha256(filename: str) -> str:
70162
"""Get the SHA256 hash of a file from its path, assuming it's a binary file like a PyTorch model."""
71163
hash = hashlib.sha256()

openff/nagl_models/openff_nagl_models.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
This module only contains the function that will be the entry point that
33
will be used to find the model files.
44
"""
5+
56
import importlib.resources
67
import os
78
import pathlib
@@ -166,7 +167,8 @@ def list_available_nagl_models() -> list[pathlib.Path]:
166167
# look for all .pt files in the cache directory, but only those that are
167168
# expected to also be found in release assets
168169
cached_paths = [
169-
cached_file for cached_file in CACHE_DIR.rglob("*.pt")
170+
cached_file
171+
for cached_file in CACHE_DIR.rglob("*.pt")
170172
if cached_file.name in KNOWN_HASHES
171173
]
172174

@@ -205,12 +207,12 @@ def get_models_by_type(
205207
--------
206208
207209
Getting the latest pre-release model for am1bcc::
208-
210+
209211
>>> from openff.nagl_models.openff_nagl_models import get_models_by_type
210212
>>> get_models_by_type(model_type="am1bcc")
211213
[PosixPath('/.../openff-nagl-models/openff/nagl_models/models/am1bcc/openff-gnn-am1bcc-0.0.1-alpha.1.pt'),
212214
PosixPath('/.../openff-nagl-models/openff/nagl_models/models/am1bcc/openff-gnn-am1bcc-0.1.0-rc.1.pt')]
213-
215+
214216
"""
215217
from packaging.version import Version
216218

@@ -221,14 +223,12 @@ def get_models_by_type(
221223
"If you are using a custom model, "
222224
"please manually specify the path to the model file."
223225
)
224-
226+
225227
model_files = pathlib.Path(base_dir).glob("*.pt")
226-
228+
227229
# assume everything follows the openff-gnn-<model_type>-<version>.pt format
228230
n_name = len(f"openff-gnn-{model_type}-")
229-
versions_to_paths = {
230-
Version(f.stem[n_name:]): f for f in model_files
231-
}
231+
versions_to_paths = {Version(f.stem[n_name:]): f for f in model_files}
232232
versions = sorted(versions_to_paths.keys())
233233
if production_only:
234234
versions = [v for v in versions if not v.is_prerelease]

openff/nagl_models/tests/test_dynamic_fetch.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import pathlib
34
import shutil
45
import urllib.request
@@ -9,7 +10,11 @@
910

1011
import openff.nagl_models._dynamic_fetch
1112
from openff.nagl_models import __file__ as root
12-
from openff.nagl_models._dynamic_fetch import get_model
13+
from openff.nagl_models._dynamic_fetch import (
14+
get_model,
15+
HashComparisonFailedException,
16+
UnableToParseDOIException,
17+
)
1318

1419

1520
def mocked_urlretrieve(url, filename):
@@ -59,11 +64,27 @@ def test_get_known_models(monkeypatch, known_model):
5964
assert "OPENFF_NAGL_MODELS" in get_model(known_model)
6065

6166

62-
def test_access_internet_with_empty_cache():
63-
cache_path = platformdirs.user_cache_path() / "OPENFF_NAGL_MODELS"
67+
@pytest.fixture
68+
def hide_cache():
69+
cache_dir = platformdirs.user_cache_path() / "OPENFF_NAGL_MODELS"
70+
alt_dir = str(cache_dir) + "_temp"
71+
72+
if os.path.exists(alt_dir):
73+
raise FileExistsError(f"Temporary directory already exists: {alt_dir}")
74+
75+
if os.path.exists(cache_dir):
76+
shutil.move(cache_dir, alt_dir)
77+
78+
yield
6479

65-
if cache_path.exists():
66-
shutil.rmtree(cache_path)
80+
if os.path.exists(alt_dir):
81+
if os.path.exists(cache_dir):
82+
shutil.rmtree(cache_dir)
83+
shutil.move(alt_dir, cache_dir)
84+
85+
86+
def test_access_internet_with_empty_cache(hide_cache):
87+
cache_path = platformdirs.user_cache_path() / "OPENFF_NAGL_MODELS"
6788

6889
disable_socket()
6990

@@ -147,3 +168,53 @@ def test_all_models_loadable(model, monkeypatch):
147168
)
148169

149170
GNNModel.load(get_model(model), eval_mode=True)
171+
172+
173+
def test_get_model_by_doi_and_hash(hide_cache):
174+
# This test uses a Zenodo sandbox DOI (10.5072 prefix) and the corresponding
175+
# SHA256 hash of the test file uploaded to that sandbox record
176+
get_model(
177+
"my_favorite_model.pt",
178+
doi="10.5072/zenodo.278300",
179+
file_hash="127eb0b9512f22546f8b455582bcd85b2521866d32b86d231fee26d4771b1d81",
180+
)
181+
182+
183+
def test_get_model_by_doi_no_hash(hide_cache):
184+
get_model("my_favorite_model.pt", doi="10.5072/zenodo.278300")
185+
186+
187+
def test_get_model_hash_comparison_fails():
188+
with pytest.raises(HashComparisonFailedException):
189+
get_model(
190+
"my_favorite_model.pt",
191+
doi="10.5072/zenodo.278300",
192+
file_hash="wrong_hash",
193+
)
194+
195+
196+
def test_user_provided_hash_conflicts_with_known_hash():
197+
with pytest.raises(HashComparisonFailedException):
198+
get_model("openff-gnn-am1bcc-0.1.0-rc.3.pt", file_hash="wrong_hash")
199+
200+
201+
def test_malformed_doi(monkeypatch, hide_cache):
202+
with monkeypatch.context() as m:
203+
m.setattr(
204+
urllib.request,
205+
"urlretrieve",
206+
mocked_urlretrieve,
207+
)
208+
m.setattr(
209+
openff.nagl_models._dynamic_fetch,
210+
"get_release_metadata",
211+
mocked_get_release_metadata,
212+
)
213+
214+
with pytest.raises(UnableToParseDOIException):
215+
get_model("my_favorite_model.pt", doi="zenodo.278300")
216+
217+
218+
def test_no_matching_file_at_doi():
219+
with pytest.raises(FileNotFoundError, match="sandbox.zenodo"):
220+
get_model("file_that_doesnt_exist.pt", doi="10.5072/zenodo.278300")

0 commit comments

Comments
 (0)