11import functools
22import hashlib
33import json
4+ import re
45import pathlib
56import urllib .request
6-
77import platformdirs
88from packaging .version import Version
99
1919
2020CACHE_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+
2231def 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+
69161def _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 ()
0 commit comments