Repository: SNEWS2/snewpy

Issue:
Make a test Zenodo files downloader
Following #139

We have one of the preSN models: https://zenodo.org/record/2626645, so this might be good enough for tests.
Test the feasibility of storing the models data on Zenodo and downloading the files from there

_Originally posted by @Sheshuk in https://github.com/SNEWS2/snewpy/discussions/139#discussioncomment-1736246_

Discussion:

Patch:
diff --git a/python/snewpy/__init__.py b/python/snewpy/__init__.py
index 1980a93..6e6f441 100644
--- a/python/snewpy/__init__.py
+++ b/python/snewpy/__init__.py
@@ -8,16 +8,13 @@ Front-end for supernova models which provide neutrino luminosity and spectra.
 """
 
 from ._version import __version__
-
-from astropy.config.paths import get_cache_dir
 import os
 
 src_path = os.path.realpath(__path__[0])
 base_path = os.sep.join(src_path.split(os.sep)[:-2])
-model_path = os.path.join(get_cache_dir(), 'snewpy/models')
 
 
-def get_models(models=None, download_dir=model_path):
+def get_models(models=None, download_dir="SNEWPY_models"):
     """Download model files from the snewpy repository.
 
     Parameters
@@ -71,7 +68,7 @@ def get_models(models=None, download_dir=model_path):
     # Download model files
     if not os.path.isdir(download_dir):
         print(f"Creating directory '{download_dir}' ...")
-        os.makedirs(download_dir)
+        os.mkdir(download_dir)
 
     def retrieve(url, local_file):
         try:
diff --git a/python/snewpy/model_files.yml b/python/snewpy/model_files.yml
new file mode 100644
index 0000000..3e076c1
--- /dev/null
+++ b/python/snewpy/model_files.yml
@@ -0,0 +1,10 @@
+presn:
+    Patton_2017: !zenodo
+      zenodo_id: 2626645
+      regex: '.*\.dat'
+      path: "/tmp/SNEWPY_models"
+
+ccsn: 
+    Bollig_2016: !local
+      regex: 's[0-9.]+c_LS220_nu(e|x)(bar)?'
+      path: "models/Bollig_2016"
diff --git a/python/snewpy/models/base.py b/python/snewpy/models/base.py
index c427d4c..e9ab24a 100644
--- a/python/snewpy/models/base.py
+++ b/python/snewpy/models/base.py
@@ -202,7 +202,7 @@ class PinchedModel(SupernovaModel):
         metadata: dict
             Model parameters dict
         """
-        if not 'L_NU_X_BAR' in simtab:
+        if not 'L_NU_X_BAR' in simtab.colnames:
             # table only contains NU_E, NU_E_BAR, and NU_X, so double up
             # the use of NU_X for NU_X_BAR.
             for val in ['L','E','ALPHA']:
diff --git a/python/snewpy/models/ccsn.py b/python/snewpy/models/ccsn.py
index d0ed3b2..484fb9e 100644
--- a/python/snewpy/models/ccsn.py
+++ b/python/snewpy/models/ccsn.py
@@ -27,10 +27,8 @@ try:
 except ImportError:
     pass
 
-from snewpy import model_path
 from snewpy.neutrino import Flavor
 from .base import PinchedModel, SupernovaModel, _GarchingArchiveModel
-from .registry import check_valid_params, get_param_combinations
 
 
 class Analytic3Species(PinchedModel):
@@ -56,132 +54,37 @@ class Nakazato_2013(PinchedModel):
     (2013), ApJ 804:75 (2015), PASJ 73:639 (2021). See also http://asphwww.ph.noda.tus.ac.jp/snn/.
     """
 
-    param = {'progenitor_mass': [13, 20, 30, 50] * u.Msun,
-             'revival_time': [0, 100, 200, 300] * u.ms,
-             'metallicity': [0.02, 0.004],
-             'eos': ['LS220', 'shen', 'togashi']}
-
-    def __init__(self, filename=None, *, progenitor_mass=None, revival_time=None, metallicity=None, eos=None):
-        """Model initialization.
-
+    def __init__(self, filename):
+        """
         Parameters
         ----------
         filename : str
             Absolute or relative path to FITS file with model data.
-
-        Other Parameters
-        ----------------
-        progenitor_mass: astropy.units.Quantity
-            Mass of model progenitor in units Msun
-        revival_time: astropy.units.Quantity
-            Time of shock revival in model in units ms
-            Selecting 0 ms will load a black hole formation model
-        metallicity: float
-            Progenitor metallicity
-        eos: str
-            Equation of state
-
-        Raises
-        ------
-        FileNotFoundError
-            If a file for the chosen model parameters cannot be found
-        ValueError
-            If a combination of parameters is invalid when loading form parameters
-
-        See also
-        --------
-        snewpy._model_registry : Describes allowed values for parameters `progenitor_mass`, `revival_time`,
-                                 `metallicity`, and `eos`
-        Examples
-        --------
-        >>> from snewpy.models.ccsn import Nakazato_2013; import astropy.units as u
-        >>> Nakazato_2013(progenitor_mass=13*u.Msun, metallicity=0.004, revival_time=0*u.s, eos='togashi')
-        Nakazato_2013 Model: nakazato-togashi-BH-z0.004-s30.0.fits
-        Progenitor mass  : 30.0 solMass
-        EOS              : Togashi
-        Metallicity      : 0.004
-        Revival time     : 0.0 ms
         """
-        # Attempt to load model from parameters
-        if not filename and all((p is not None for p in (progenitor_mass, revival_time, metallicity, eos))):
-
-            user_params = dict(zip(self.param.keys(), (progenitor_mass, revival_time, metallicity, eos)))
-            try:
-                check_valid_params(**user_params)
-            except ValueError as e:
-                raise e
-
-            # Populate model parameter members
-            self.progenitor_mass = progenitor_mass.to(u.Msun)
-            self.revival_time = revival_time.to(u.ms)
-            self.metallicity = metallicity
-            self.EOS = eos
-
-            # Strip units for filename construction
-            progenitor_mass = progenitor_mass.to(u.Msun).value
-            revival_time = revival_time.to(u.ms).value
-
-            if revival_time != 0:
-                fname = f"nakazato-{eos}-z{metallicity}-t_rev{int(revival_time)}ms-s{progenitor_mass:3.1f}.fits"
-            else:
-                fname = f"nakazato-{eos}-BH-z{metallicity}-s{progenitor_mass:3.1f}.fits"
-
-            # Construct metadata
-            filename = os.path.join(model_path, self.__class__.__name__, fname)
-            if not os.path.exists(filename):
-                raise FileNotFoundError(f"No such file or directory: '{filename}', requested parameters may be "
-                                        "incompatible")
-            metadata = {
-                'Progenitor mass': progenitor_mass * u.Msun,
-                'EOS': eos.capitalize(),
-                'Metallicity': metallicity,
-                'Revival time': revival_time * u.ms
-            }
-        elif filename:
-            # Store model metadata.
-            if 't_rev' in filename:
-                self.progenitor_mass = float(filename.split('-')[-1].strip('s%.fits')) * u.Msun
-                self.revival_time = float(filename.split('-')[-2].strip('t_rev%ms')) * u.ms
-                self.metallicity = float(filename.split('-')[-3].strip('z%'))
-                self.EOS = filename.split('-')[-4].upper()
-            # No revival time because the explosion "failed" (BH formation).
-            else:
-                self.progenitor_mass = float(filename.split('-')[-1].strip('s%.fits')) * u.Msun
-                self.metallicity = float(filename.split('-')[-2].strip('z%'))
-                self.revival_time = 0 * u.ms
-                self.EOS = filename.split('-')[-4].upper()
-
-            metadata = {
-                'Progenitor mass': self.progenitor_mass,
-                'EOS': self.EOS,
-                'Metallicity': self.metallicity,
-                'Revival time': self.revival_time
-            }
+        # Store model metadata.
+        if 't_rev' in filename:
+            self.progenitor_mass = float(filename.split('-')[-1].strip('s%.fits')) * u.Msun
+            self.revival_time = float(filename.split('-')[-2].strip('t_rev%ms')) * u.ms
+            self.metallicity = float(filename.split('-')[-3].strip('z%'))
+            self.EOS = filename.split('-')[-4].upper()
+        # No revival time because the explosion "failed" (BH formation).
         else:
-            raise TypeError('__init__() missing required arguments. Use argument `filename` or arguments '
-                            '`progenitor_mass`, `revival_time`, `metallicity`, `eos`')
+            self.progenitor_mass = float(filename.split('-')[-1].strip('s%.fits')) * u.Msun
+            self.metallicity = float(filename.split('-')[-2].strip('z%'))
+            self.revival_time = 0 * u.ms
+            self.EOS = filename.split('-')[-4].upper()
 
+        metadata = {
+            'Progenitor mass':self.progenitor_mass,
+            'EOS':self.EOS,
+            'Metallicity':self.metallicity,
+            'Revival time':self.revival_time
+            }
         # Read FITS table using the astropy reader.
         simtab = Table.read(filename)
         self.filename = os.path.basename(filename)
         super().__init__(simtab, metadata)
 
-    @classmethod
-    def get_param_combinations(cls, *, progenitor_mass=None, revival_time=None, metallicity=None, eos=None):
-        user_param = dict(zip(cls.param.keys(), (progenitor_mass, revival_time, metallicity, eos)))
-        return get_param_combinations(cls, **user_param)
-
-    @staticmethod
-    def isvalid_param_combo(progenitor_mass, revival_time, metallicity, eos):
-        """Returns True if the parameter combination is valid (Corresponds to an existing Nakazato_2013 model)
-        See __init__ for a full parameter descriptions.
-        """
-        if revival_time.to(u.s).value == 0:
-            return progenitor_mass.to(u.Msun).value == 30 and metallicity == 0.004
-        else:
-            return eos in ['shen'] and not (progenitor_mass.to(u.Msun).value == 30 and metallicity == 0.004)
-
-
 
 class Sukhbold_2015(PinchedModel):
     """Model based on simulations from Sukhbold et al., ApJ 821:38,2016. Models were shared privately by email.
@@ -251,14 +154,14 @@ class OConnor_2013(PinchedModel):
 
         # Open luminosity file.
         tf = tarfile.open(base+'{}_timeseries.tar.gz'.format(eos))
-
+        
         # Extract luminosity data.
         dataname = 's{:d}_{}_timeseries.dat'.format(mass, eos)
         datafile = tf.extractfile(dataname)
         simtab = ascii.read(datafile, names=['TIME', 'L_NU_E', 'L_NU_E_BAR', 'L_NU_X',
                                                'E_NU_E', 'E_NU_E_BAR', 'E_NU_X',
                                                'RMS_NU_E', 'RMS_NU_E_BAR', 'RMS_NU_X'])
-
+        
         simtab['ALPHA_NU_E'] = (2.0*simtab['E_NU_E']**2 - simtab['RMS_NU_E']**2)/(simtab['RMS_NU_E']**2 - simtab['E_NU_E']**2)
         simtab['ALPHA_NU_E_BAR'] = (2.0*simtab['E_NU_E_BAR']**2 - simtab['RMS_NU_E_BAR']**2)/(simtab['RMS_NU_E_BAR']**2 - simtab['E_NU_E_BAR']**2)
         simtab['ALPHA_NU_X'] = (2.0*simtab['E_NU_X']**2 - simtab['RMS_NU_X']**2)/(simtab['RMS_NU_X']**2 - simtab['E_NU_X']**2)
@@ -350,8 +253,9 @@ class Zha_2021(PinchedModel):
         simtab['L_NU_E_BAR'][simtab['L_NU_E_BAR'] < 0] = 1
         simtab['L_NU_X'][simtab['L_NU_X'] < 0] = 1
 
+        
         basename =os.path.basename(filename)[:-4]
-
+        
         self.filename = 'Zha2021_'+basename
         self.EOS = eos
         self.progenitor_mass =  float(basename[1:])* u.Msun
@@ -367,7 +271,7 @@ class Warren_2020(PinchedModel):
     """Model based on simulations from Warren et al., ApJ 898:139, 2020.
     Neutrino fluxes available at https://doi.org/10.5281/zenodo.3667908."""
 
-    def __init__(self, filename, eos='LS220'):
+    def __init__(self, filename, eos='SFHo'):
         """
         Parameters
         ----------
@@ -638,7 +542,7 @@ class Fornax_2019(SupernovaModel):
             hdu_flux.header['EXTNAME'] = '{}_FLUX'.format(name)
             hdu_flux.header['BUNIT'] = str(self.fluxunit)
             hx.append(hdu_flux)
-
+        
         hx.writeto(filename, overwrite=overwrite)
 
     def _fact(self, n):
@@ -713,7 +617,7 @@ class Fornax_2019(SupernovaModel):
         # Convert input time to a time index.
         t = t.to(self.time.unit)
         j = (np.abs(t - self.time)).argmin()
-
+        
         for flavor in Flavor:
             # Cached data: read out the relevant time and angular rows.
             if self.is_cached:
@@ -737,7 +641,7 @@ class Fornax_2019(SupernovaModel):
                 # Energy binning of the model for this flavor, in units of MeV.
                 E[flavor]  = self._h5file[key]['egroup'][j] * u.MeV
                 dE[flavor] = self._h5file[key]['degroup'][j] * u.MeV
-
+                
                 # Storage of differential flux per energy, angle, and time.
                 dLdE = np.zeros(len(E[flavor]), dtype=float)
 
@@ -788,7 +692,7 @@ class Fornax_2019(SupernovaModel):
         # Avoid "division by zero" in retrieval of the spectrum.
         E[E == 0] = np.finfo(float).eps * E.unit
         logE = np.log10(E.to_value('MeV'))
-
+        
         for flavor in flavors:
 
             # Linear interpolation in flux.
diff --git a/python/snewpy/models/registry.py b/python/snewpy/models/registry.py
deleted file mode 100644
index 6dd2d78..0000000
--- a/python/snewpy/models/registry.py
+++ /dev/null
@@ -1,225 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-A submodule with classes used for accessing supernova model files stored on disk.
-It assumes models are available under a directory `data_folder` that is specified when
-SNEWPY is installed under /home/user/.astropy/cache via snewpy.__init__.py.
-"""
-
-
-from astropy.units.quantity import Quantity
-from astropy.units import UnitTypeError, get_physical_type
-from snewpy import model_path, get_models
-import logging
-from . import ccsn, presn
-import itertools as it
-
-
-def init_model(model_name, download=True, download_dir=model_path, **user_param):
-    """Attempts to retrieve instantiated SNEWPY model using model class name and model parameters.
-    If a model name is valid, but is not found and `download`=True, this function will attempt to download the model
-
-    Parameters
-    ----------
-    model_name : str
-        Name of SNEWPY model to import, must exactly match the name of the corresponding model class
-    download : bool
-        Switch for attempting to download model data if the first load attempt failed due to a missing file.
-    download_dir : str
-        Local directory to download model files to.
-    user_param : varies
-        User-requested model parameters used to initialize the model, if one is found.
-        Error checking is performed during model initialization
-
-    Raises
-    ------
-    ValueError
-        If the requested model_name does not match any SNEWPY models
-
-    See Also
-    --------
-    snewpy.models.ccsn
-    snewpy.models.presn
-
-    Example
-    -------
-    >>> from snewpy.models.registry import init_model; import astropy.units as u
-    >>> init_model('Nakazato_2013', progenitor_mass=13*u.Msun, metallicity=0.004, revival_time=0*u.s, eos='shen')
-    Nakazato_2013 Model: nakazato-shen-BH-z0.004-s30.0.fits
-    Progenitor mass  : 30.0 solMass
-    EOS              : Shen
-    Metallicity      : 0.004
-    Revival time     : 0.0 ms
-    """
-    if model_name in dir(ccsn):
-        module = ccsn
-    elif model_name in dir(presn):
-        module = presn
-    else:
-        raise ValueError(f"Unable to find model with name '{model_name}' in snewpy.models.ccsn or snewpy.models.presn")
-
-    try:
-        return getattr(module, model_name)(**user_param)
-    except FileNotFoundError as e:
-        logger = logging.getLogger()
-        logger.warning(f"Unable to find model {model_name} in {download_dir}")
-        if not download:
-            raise e
-        logger.warning(f"Attempting to download model...")
-        get_models(model_name, download_dir)
-        return getattr(module, model_name)(**user_param)
-
-
-def check_param_values(model, **user_param):
-    """Checks that the model-specific values and units of requested parameters are valid according to model.param.
-
-    Parameters
-    ----------
-    model : snewpy.model.SupernovaModel
-        Model class used to perform parameter check
-    user_param : varies
-        User-requested model parameters to be tested for validity.
-        NOTE: This must be provided as kwargs that match the keys of model.param
-
-    Raises
-    ------
-    ValueError
-        If invalid model parameters are provided based on units, allowed values, etc.
-    UnitTypeError
-        If invalid units are provided for a model parameter
-
-    See Also
-    --------
-    snewpy.models.ccsn
-    snewpy.models.presn
-
-    """
-    model_param = model.param
-    # Check that the appropriate number of params are provided
-    check_param_names(model, **user_param)
-
-    # Check that user-requested params have valid units and values
-    for (key, allowed_params), user_param in zip(model_param.items(), user_param.values()):
-        # If both have units, check that the user param value is valid. If valid, continue. Else, error
-        if type(user_param) == Quantity and type(allowed_params) == Quantity:
-            if get_physical_type(user_param.unit) != get_physical_type(allowed_params.unit):
-                raise UnitTypeError(f"Incorrect units {user_param.unit} provided for parameter {key}, "
-                                    f"expected {allowed_params.unit}")
-            elif user_param.to(allowed_params.unit).value in allowed_params.value:
-                continue
-            else:
-                raise ValueError(f"Invalid value '{user_param}' provided for parameter {key}, "
-                                 f"allowed value(s): {allowed_params}")
-
-        # If one only one has units, then error
-        elif (type(user_param) == Quantity) ^ (type(allowed_params) == Quantity):
-            # User param has units, model param is unitless
-            if type(user_param) == Quantity:
-                raise ValueError(f"Invalid units {user_param.unit} for parameter {key} provided, expected None")
-            else:
-                raise ValueError(f"Missing units for parameter {key}, expected {allowed_params.unit}")
-
-        # Check that unitless user param value is valid. If valid, continue. Else, Error
-        elif user_param in allowed_params:
-            continue
-        else:
-            raise ValueError(f"Invalid value '{user_param}' provided for parameter {key}, "
-                             f"allowed value(s): {allowed_params}")
-
-
-def check_param_combo(model, **user_param):
-    """Checks that the model-specific combination of requested parameters is valid according to
-    model.isvalid_param_combo. Valid parameter combinations may be found via model.get_param_combinations().
-
-    Parameters
-    ----------
-    model : snewpy.model.SupernovaModel
-        Model class used to perform parameter combination check
-    user_param : varies
-        User-requested model parameters to be tested for validity.
-        NOTE: This must be provided as kwargs that match the keys of model.param
-
-    Raises
-    ------
-    ValueError
-        If invalid combination of model parameters are provided.
-
-    See Also
-    --------
-    snewpy.models.ccsn
-    snewpy.models.presn
-    """
-    check_param_names(model, **user_param)
-    if not model.isvalid_param_combo(**user_param):
-        raise ValueError(f"Invalid parameter combination. See {model.__name__}.param_combinations for a list of "
-                         "allowed parameter combinations.")
-
-
-def check_param_names(model, **user_param):
-    """Checks that all requested model parameter names match the required model parameter names according to model.param
-    See the __init__ of a specific model for descriptions of the available parameters.
-
-    Parameters
-    ----------
-    model : snewpy.model.SupernovaModel
-        Model class used to build combinations
-    user_param : varies
-        User-requested model parameters used to perform checks
-        NOTE: This must be provided as kwargs that match the keys of model.param
-    """
-    if not all(key in user_param for key in model.param.keys()):
-        raise ValueError(f"Missing parameter! Expected {model.param.keys()} but was given {user_param.keys()}")
-
-
-def check_valid_params(model, **user_param):
-    """Checks for valid model parameter combination validity
-    See the __init__ of a specific model for descriptions of the available parameters.
-
-    Parameters
-    ----------
-    model : snewpy.model.SupernovaModel
-        Model class used to build combinations
-    user_param : varies
-        User-requested model parameters used to perform checks
-        NOTE: This must be provided as kwargs that match the keys of model.param
-    """
-    # Check parameters for valid values and units
-    check_param_values(model, **user_param)
-    # Check parameter combination for validity (model-specific)
-    check_param_combo(model, **user_param)
-
-
-def get_param_combinations(model, **user_param):
-    """Returns all valid combinations of parameters for a given model. If specific parameters are provided as a
-    keyword argument, this will return only those combinations that match the requested parameters.
-    See the __init__ of a specific model for descriptions of the available parameters.
-
-    Parameters
-    ----------
-    model : snewpy.model.SupernovaModel
-        Model class used to build combinations
-    user_param : varies
-        User-requested model parameters matched against valid combinations.
-        NOTE: This must be provided as a kwargs that match the keys of model.param
-
-    Returns
-    -------
-    valid_combinations: tuple[dict]
-        A tuple of all valid combinations matching the requested parameters (if any). Combinations are stored as
-        dictionaries that are ready-to-use for model instantiation. If no valid combinations match the given parameters,
-        a zero-length tuple will be returned.
-
-    Raises
-    ------
-    ValueError
-        If an invalid value for a model parameter is provided.
-    """
-    check_param_names(model, **user_param)
-    param = {}
-    for key, val, user_val in zip(model.param.keys(), model.param.values(), user_param.values()):
-        if user_val is not None and user_val in val:
-            param.update({key: [user_val]})
-        elif user_val is not None and user_val not in val:
-            raise ValueError(f"Invalid value for parameter {key}. Given {user_val}, but expected one from {val}")
-        else:
-            param.update({key: val})
-    return tuple(dict(zip(param, c)) for c in it.product(*param.values()) if model.isvalid_param_combo(*c))
diff --git a/python/snewpy/zenodo_downloader.py b/python/snewpy/zenodo_downloader.py
new file mode 100644
index 0000000..bfec70f
--- /dev/null
+++ b/python/snewpy/zenodo_downloader.py
@@ -0,0 +1,123 @@
+from dataclasses import dataclass
+from pathlib import Path
+import requests
+import re
+from contextlib import contextmanager, AbstractContextManager
+from tqdm.auto import tqdm
+import hashlib
+
+import logging
+logger = logging.getLogger('FileHandle')
+
+def _md5(fname:str) -> str:
+    """calculate the md5sum hash of a file"""
+    hash_md5 = hashlib.md5()
+    with open(fname, "rb") as f:
+        for chunk in iter(lambda: f.read(4096), b""):
+            hash_md5.update(chunk)
+    return hash_md5.hexdigest()
+
+def _download(src:str, dest:str, chunk_size=8192):
+    """Download a file from 'src' to 'dest' and show the progress bar"""
+    #make sure parent dir exists
+    Path(dest).parent.mkdir(exist_ok=True, parents=True)
+    with requests.get(src, stream=True) as r:
+        r.raise_for_status()
+        fileSize = int(r.headers.get('content-length', 0))
+        with tqdm(desc=dest.name, total=fileSize, unit='iB', unit_scale=True, unit_divisor=1024) as bar:
+            with open(dest, 'wb') as f:
+                for chunk in r.iter_content(chunk_size=chunk_size):
+                    size = f.write(chunk)
+                    bar.update(size)
+            
+class ChecksumError(FileNotFoundError):
+    def __init__(self, path, md5_exp, md5_actual):
+        super().__init__(f'Checksum error for file {path}: {md5_actual}!={md5_exp}')
+    pass
+class MissingFileError(FileNotFoundError):
+    pass
+
+@dataclass
+class FileHandle:
+    path: Path
+    remote: str = None
+    md5: str|None = None
+    
+    def check(self) -> None:
+        """Check if the given file exists locally and has a correct md5 sum.
+        Raises
+        ------
+        :class:`MissingFileError`
+            if the local copy of the file is missing
+        :class:`ChecksumError` 
+            if the local file exists, but the checksum is wrong"""
+        if not self.path.exists():
+            raise MissingFileError(self.path)
+        if self.md5:
+            logger.info(f'File {self.path}: checking md5')
+            md5 = _md5(self.path)
+            logger.debug(f'{md5} vs expected {self.md5}')
+            if (md5 != self.md5):
+                raise ChecksumError(self.path, self.md5, md5)
+    
+    def load(self) -> Path:
+        """Make sure that local file exists and has a correct checksum
+        Download the file if needed
+        """
+        try:
+            self.check()
+        except FileNotFoundError as e:
+            logger.info(f'Downloading file {self.path}')
+            _download(self.remote, self.path)
+            self.check()
+        finally:
+            return self.path
+
+    def open(self, flags='r'):
+        """ Load and open the local file, return the file object"""
+        return open(self.load(), flags)
+
+def from_zenodo(zenodo_id:str, path:str='/tmp/', regex: str = '.*'):
+    """Load files from Zenodo record"""
+    path = Path(path)/str(zenodo_id)
+    path.mkdir(exist_ok=True, parents=True)
+    files_re = re.compile(regex)
+    files = {}
+
+    zenodo_url = f'https://zenodo.org/api/records/{zenodo_id}'
+    record = requests.get(zenodo_url).json()
+    for f in record['files']:
+        if files_re.match(f["key"]):
+            files[f['key']] = FileHandle(path = path/f['key'],
+                                         remote= f['links']['self'],
+                                         md5 = f['checksum'].lstrip('md5:')
+                                        )
+    return files
+
+def from_local(path:str, regex: str = '.*'):
+    """Load files from local places"""
+    path = Path(path)
+    files_re = re.compile(regex)
+    files = {}
+
+    for f in path.iterdir():
+        if files_re.match(f.name):
+            files[f.name] = FileHandle(path = f)
+    return files
+
+import yaml
+
+def load_registry(fname):
+    loader = yaml.SafeLoader
+
+    def _construct_from_zenodo(loader, node):
+        return from_zenodo(**loader.construct_mapping(node))
+
+    def _construct_from_local(loader, node):
+        return from_local(**loader.construct_mapping(node))
+
+    loader.add_constructor('!zenodo', _construct_from_zenodo)
+    loader.add_constructor('!local', _construct_from_local)
+
+    with open(fname) as f:
+        return yaml.load(f, loader)


Test patch:
diff --git a/python/snewpy/test/test_models.py b/python/snewpy/test/test_models.py
index f009491..52e187f 100644
--- a/python/snewpy/test/test_models.py
+++ b/python/snewpy/test/test_models.py
@@ -225,7 +225,7 @@ class TestModels(unittest.TestCase):
 
                 self.assertEqual(model.progenitor_mass, float(mass)*u.Msun)
                 self.assertEqual(model.turbmixing_param, mixing)
-                self.assertEqual(model.EOS, 'LS220')
+                self.assertEqual(model.EOS, 'SFHo')
 
                 # Check that times are in proper units.
                 t = model.get_time()