# Licensed under a 3-clause BSD style license - see LICENSE.rst
# -*- coding: utf-8 -*-
"""
===============
desiutil.depend
===============
Utilities for working with code dependencies stored in FITS headers.
The code name and the code version are stored in pairs of keywords, similar
to how table columns are defined. *e.g.*::
DEPNAM00 = 'numpy'
DEPVER00 = '1.11'
DEPNAM01 = 'desiutil'
DEPVER01 = '1.4.1'
The functions and Dependencies class provide convenience wrappers
to loop over they keywords looking for a particular dependency
and adding a new dependency version to next available DEPNAMnn/DEPVERnn.
Examples:
>>> import desiutil
>>> from desiutil import depend
>>> import astropy
>>> from astropy.io import fits
>>>
>>> hdr = fits.Header()
>>> depend.setdep(hdr, 'desiutil', desiutil.__version__)
>>> depend.setdep(hdr, 'astropy', astropy.__version__)
>>> depend.getdep(hdr, 'desiutil')
'1.4.1.dev316'
>>> depend.hasdep(hdr, 'astropy')
True
>>> hdr
DEPNAM00= 'desiutil'
DEPVER00= '1.4.1.dev316'
DEPNAM01= 'astropy '
DEPVER01= '1.1.1 '
There is also an object wrapper that gives a header dict-like semantics to
update or view dependencies. This directly updates the input header object
so that it can be used in subsequent I/O
>>> codever = depend.Dependencies(hdr)
>>> codever['blat'] = '1.2'
>>> codever['foo'] = '3.4'
>>> for codename, version in codever.items():
... print(codename, version)
...
('desiutil', '1.4.1.dev316')
('astropy', u'1.1.1')
('blat', '1.2')
('foo', '3.4')
"""
import os
import sys
import importlib
#
# default possible dependencies to check in add_dependencies()
#
possible_dependencies = [
'numpy', 'scipy', 'astropy', 'yaml', 'matplotlib',
'requests', 'fitsio', 'h5py', 'mpi4py', 'psycopg2', 'healpy',
'desiutil', 'desispec', 'desitarget', 'desimodel', 'desisim', 'desisurvey',
'specter', 'speclite', 'specsim', 'surveysim', 'redrock', 'desimeter',
'fiberassign', 'gpu_specter',
]
possible_envvars = [
'DESI_ROOT', 'DESI_SPECTRO_DATA', 'DESI_SPECTRO_REDUX', 'SPECPROD',
'DESI_SPECTRO_CALIB', 'DESI_BASIS_TEMPLATES',
'DESI_TARGET', 'DESIMODEL',
]
[docs]def setdep(header, name, version):
'''Set dependency `version` for code `name`.
Parameters
----------
header : dict-like
A dict-like object, *e.g.* :class:`astropy.io.fits.Header`.
name : :class:`str`
Code name string.
version : :class:`str`
Code version string.
Raises
------
IndexError
If there are more than 100 dependencies to track.
'''
for i in range(100):
namekey = 'DEPNAM{:02d}'.format(i)
verkey = 'DEPVER{:02d}'.format(i)
if namekey in header:
if header[namekey] == name:
header[verkey] = version
return
else:
header[namekey] = name
header[verkey] = version
return
# if we got this far, we ran out of numbers
raise IndexError("Too many versions to track!")
[docs]def getdep(header, name):
'''Get dependency version for code `name`.
Parameters
----------
header : dict-like
A dict-like object, *e.g.* :class:`astropy.io.fits.Header`.
name : :class:`str`
Code name string.
Returns
-------
:class:`str`
The version string for `name`.
Raises
------
KeyError
If `name` not tracked in `header`.
'''
for i in range(100):
namekey = 'DEPNAM{:02d}'.format(i)
verkey = 'DEPVER{:02d}'.format(i)
if namekey in header and header[namekey] == name:
return header[verkey]
# if we exited the loop, the name wasn't found in any DEPNAMnn
raise KeyError('{} version not found'.format(name))
[docs]def hasdep(header, name):
'''Check if `name` is defined in `header`.
Parameters
----------
header : dict-like
A dict-like object, *e.g.* :class:`astropy.io.fits.Header`.
name : :class:`str`
Code name string.
Returns
-------
:class:`bool`
``True`` if version for `name` is tracked in `header`, otherwise ``False``.
'''
try:
version = getdep(header, name)
return True
except KeyError:
return False
[docs]def iterdep(header):
'''Returns iterator over (codename, version) tuples.
Parameters
----------
header : dict-like
A dict-like object, *e.g.* :class:`astropy.io.fits.Header`.
'''
for i in range(100):
namekey = 'DEPNAM{:02d}'.format(i)
verkey = 'DEPVER{:02d}'.format(i)
if namekey in header:
yield (header[namekey], header[verkey])
else:
continue # ok if some DEPNAMnn are missing
return
[docs]def mergedep(srchdr, dsthdr, conflict='src'):
'''Merge dependencies from srchdr into dsthdr
Parameters
----------
srchdr : dict-like
source dict-like object, *e.g.* :class:`astropy.io.fits.Header`,
with dependency keywords DEPNAMnn, DEPVERnn
dsthdr : dict-like
destination dict-like object
conflict : str, optional
'src' or 'dst' or 'exception'; see notes
Notes
-----
Dependencies in srchdr are added to dsthdr, modifying it in-place,
adjusting DEPNAMnn/DEPVERnn numbering as needed. If the same dependency
appears in both headers with different versions, ``conflict``
controls the behavior:
* if 'src', the srchdr value replaces the dsthdr value
* if 'dst', the dsthdr value is retained unchanged
* if 'exception', raise a ValueError exception
Raises
------
ValueError
If ``conflict == 'exception'`` and the same dependency name appears
in both headers with different values; or if `conflict` isn't one
of 'src', 'dst', or 'exception'.
'''
if conflict not in ('src', 'dst', 'exception'):
raise ValueError(f"conflict ({conflict}) should be 'src', 'dst', or 'exception'")
for name, version in iterdep(srchdr):
if hasdep(dsthdr, name) and getdep(dsthdr, name) != version:
if conflict == 'src':
setdep(dsthdr, name, version)
elif conflict == 'dst':
pass
else:
v2 = getdep(dsthdr, name)
raise ValueError(f'Version conflict for {name}: {version} != {v2}')
else:
setdep(dsthdr, name, version)
[docs]def removedep(header, name):
"""
Removed dependency ``name`` from ``header``.
Parameters
----------
header : dict-like
header object with DEPNAMnn/DEPVERnn keywords
name : str
name of dependency to remove
Notes
-----
Modifies header in-place
Raises
------
ValueError
If ``name`` is not present in any of the DEPNAMnn keys
"""
for i in range(100):
namekey = 'DEPNAM{:02d}'.format(i)
verkey = 'DEPVER{:02d}'.format(i)
if (namekey in header) and (header[namekey] == name):
del header[namekey]
del header[verkey]
break
else:
raise ValueError(f'{name} not found in header DEPNAMnn keywords')
[docs]def add_dependencies(header, module_names=None, long_python=False,
envvar_names=None):
'''Adds ``DEPNAMnn``, ``DEPVERnn`` keywords to header for imported modules.
Parameters
----------
header : dict-like
A dict-like object, *e.g.* :class:`astropy.io.fits.Header`.
module_names : :class:`list`, optional
List of of module names to check; if ``None``,
checks ``desiutil.depend.possible_dependencies``.
long_python : :class:`bool`, optional
If ``True`` use the full, verbose ``sys.version``
string for the Python version. Otherwise, use a short
version, *e.g.*, ``3.5.2``.
envvar_names : :class:`list`, optional
List of of environment variables to check; if ``None``,
checks ``desiutil.depend.possible_envvars``.
Notes
-----
Only adds the dependency keywords if the module has already been
previously loaded in this python session. Uses ``module.__version__``
if available, otherwise ``unknown (/path/to/module/)``.
'''
py_version = ".".join(map(str, sys.version_info[0:3]))
if long_python:
py_version = sys.version.replace('\n', ' ')
setdep(header, 'python', py_version)
if module_names is None:
module_names = possible_dependencies
if envvar_names is None:
envvar_names = possible_envvars
# Set version strings only for modules that have already been loaded
for module in module_names:
if module in sys.modules:
# already loaded, but we need a reference to the module object
x = importlib.import_module(module)
if hasattr(x, '__version__'):
version = x.__version__
elif hasattr(x, '__path__'):
# e.g. redmonster doesn't set __version__
version = 'unknown ({})'.format(x.__path__[0])
elif hasattr(x, '__file__'):
version = 'unknown ({})'.format(x.__file__)
else:
version = 'unknown'
setdep(header, module, version)
for envvar in envvar_names:
if envvar in os.environ:
setdep(header, envvar, os.environ[envvar])
else:
setdep(header, envvar, 'NOT_SET')
[docs]def remove_dependencies(header):
"""
Remove all DEPNAMnn/DEPVERnn dependencies from a header
Parameters
----------
header : dict-like
header with DEPNAMnn/DEPVERnn keywords to remove
Notes
-----
Updates header in-place
"""
# Assemble version keys first to avoid removing while iterating
keys = list()
for name, version in iterdep(header):
keys.append(name)
# now remove all keys
for name in keys:
removedep(header, name)
[docs]class Dependencies(object):
"""Dictionary-like object to track dependencies.
Parameters
----------
header : dict-like, optional
A dict-like object. If not provided, a :class:`~collections.OrderedDict`
will be used.
"""
def __init__(self, header=None):
'''Initialize Dependencies with dict-like header object.
If header is None, use a empty :class:`~collections.OrderedDict`.
'''
if header is None:
from collections import OrderedDict
self.header = OrderedDict()
else:
self.header = header
def __setitem__(self, name, version):
'''Sets version of `name`.'''
setdep(self.header, name, version)
def __getitem__(self, name):
'''Returns version of `name`.'''
return getdep(self.header, name)
def __iter__(self):
'''Returns iterator over name.'''
for name, version in iterdep(self.header):
yield name
[docs] def items(self):
'''Returns iterator over (name, version).'''
return iterdep(self.header)