unlisted
ubuntu2004# -*- coding: utf-8 -*-12import os3import pickle4import bz25import warnings6import urllib.parse7from urllib.request import urlretrieve, urlopen8from urllib.error import HTTPError9import configparser10import string1112from sage.rings.rational_field import QQ13from sage.modules.free_module_element import vector14from sage.misc.cachefunc import CachedFunction, dict_key15from sage.misc.decorators import decorator_keywords16try:17# NOTE: moved in sage 9.718from sage.misc.instancedoc import instancedoc19except ImportError:20from sage.docs.instancedoc import instancedoc212223@instancedoc24class FileCachedFunction(CachedFunction):25r"""26Function wrapper that implements a cache extending SageMath's CachedFunction.2728Preface a function definition with @file_cached_function to wrap it. When29the wrapped function is called the following locations are visited to try30and obtain the output data:31- first the cache in working memory32- then the local filesystem33- then the internet If the data is not stored in any of these locations34then the wrapped function is executed. The output of the function is35saved in the working memory cache and as a file in the filesystem.3637By default, the file is saved in the directory given by the directory38argument in the current working directory. If a name for a environment39variable is supplied via the env_var argument and this environment variable40is set to a valid path, then this director is used instead of the current41working directory.4243A filename is generated from the function arguments. The default44implementation requires that45- the arguments are hashable and convertible to strings via str(),46- the resulting strings do not contain any characters not allowed in file47names.4849The remote_database_list accepts the url of a file that should contain a50list of all files in the remote cache, one file per line.51This allows bulk downloading the files with the download_all() method.5253The key argument accepts a callable to generate the cache key from the54function arguments. For details see the documentation of CachedFunction.5556The filename argument accepts a callable that generates the file name from57the function name and the key. Whenever key is provided, filename must be58provided, too.5960EXAMPLES::6162sage: from admcycles.file_cache import file_cached_function, ignore_args_key, ignore_args_filename63sage: from tempfile import mkdtemp64sage: from shutil import rmtree65sage: import os6667sage: tmpdir = mkdtemp()68sage: # We ignore the second argument for caching69sage: @file_cached_function(directory=tmpdir, key=ignore_args_key([1]), filename=ignore_args_filename())70....: def f(a, b=True):71....: pass72sage: f(1)73sage: assert os.path.exists(os.path.join(tmpdir, "f_1.pkl.bz2"))74....:75sage: os.environ["TEST_CACHE_DIR"] = tmpdir76sage: @file_cached_function(directory="", env_var="TEST_CACHE_DIR")77....: def f(a, b=True):78....: pass79sage: f(1)80sage: assert os.path.exists(os.path.join(tmpdir, "f_1_True.pkl.bz2"))81sage: rmtree(tmpdir)82"""83def __init__(self, f, directory, url=None, remote_database_list=None, env_var=None, key=None, filename=None, pickle_wrappers=(None, None)):84self.env_var = env_var85if env_var is not None:86try:87env_dir = os.environ[env_var]88if not os.path.isdir(env_dir):89warnings.warn("%s=%s is not a directory. Ignoring it." % env_var, env_dir)90else:91directory = os.path.join(env_dir, directory)92except KeyError:93pass9495if key is not None and filename is None:96raise ValueError("If key is provided, filename must also be provided")9798super(FileCachedFunction, self).__init__(f, key=key)99self.directory = directory100self.url = url101self.remote_database_list = remote_database_list102if self.url is None:103self.go_online = False104else:105self.go_online = self.__get_online_lookup_default()106self.filename = filename107self.pickle_wrapper, self.unpickle_wrapper = pickle_wrappers108109def __call__(self, *args, **kwds):110k = self.get_key(*args, **kwds)111112# First try to return the value from the cache.113try:114return self.cache[k]115except TypeError: # k is not hashable116k = dict_key(k)117try:118return self.cache[k]119except KeyError:120pass121except KeyError:122pass123124# If the value is not in the cache, check if the cache file exists.125# If not, maybe try to download it126# Note: We prefix the filename with the function name to avoid collisions if mutliple127# functions are cached in the same directory.128(filename, filename_with_path) = self.filename_from_args(k)129if not os.path.exists(filename_with_path) and self.go_online:130try:131self.__download(filename, filename_with_path)132except IOError:133pass134135# If the cache file exists now, try to load it.136if os.path.exists(filename_with_path):137try:138dat = self.__load_from_file(filename_with_path)139self.cache[k] = dat140return dat141except IOError:142pass143except TypeError:144warnings.warn("can not unpickle file %s, it was probably created with a newer version of SageMath.")145146# If we reach this, then all methods to retrive the data from the cache have failed.147dat = self.f(*args, **kwds)148self.__save(k, dat)149return dat150151def __config_file(self):152r"""153Returns the path to the configuration file.154"""155return os.path.join(self.directory, "filecache.ini")156157def __get_online_lookup_default(self):158r"""159Tries to obtain a user specified value from the config file.160Returns True if this is not possible.161"""162cf = self.__config_file()163config = configparser.ConfigParser()164config.read(cf)165try:166if config[self.f.__name__]['online_lookup'] == 'no':167return False168except KeyError:169pass170return True171172def set_online_lookup_default(self, b):173r"""174Saves the default for online lookup in the configuration file.175"""176self.set_online_lookup(b)177cf = self.__config_file()178config = configparser.ConfigParser()179config.read(cf)180if b:181config[self.f.__name__] = {'online_lookup': 'yes'}182else:183config[self.f.__name__] = {'online_lookup': 'no'}184with open(cf, "w") as configfile:185config.write(configfile)186187def set_online_lookup(self, b):188r"""189Temporarily set whether online lookup is active.190Use func:`set_online_lookup_default` to save a default.191192It is set to the boolean ``b``.193"""194if b and self.url is None:195raise ValueError("no online database available for this function")196self.go_online = b197198def set_cache(self, dat, *args, **kwds):199r"""200Manually add a value to the cache.201202EXAMPLES::203204sage: from admcycles.file_cache import file_cached_function205sage: from tempfile import mkdtemp206sage: from shutil import rmtree207sage: tmpdir = mkdtemp()208sage: @file_cached_function(directory=tmpdir)209....: def f(a, b=True):210....: pass211sage: f.set_cache("test", 1, b=False)212sage: assert f(1, False) == "test" # This is the cached value213sage: f.clear_cache()214sage: f(1, False)215'test'216sage: rmtree(tmpdir)217218The above output "test" is the file cached value, as f returns None.219"""220k = self.get_key(*args, **kwds)221self.__save(k, dat)222223def __create_directory(self):224r"""225Creates the directory if it does not exist yet.226May throw an OSError.227"""228try:229if not os.path.isdir(self.directory):230os.mkdir(self.directory)231except OSError as e:232print("Can not create directory", self.directory, e)233raise e234235def __save(self, k, dat):236r"""237Saves the data in the cache file and the in-memory cache.238239EXAMPLES::240241sage: from admcycles.file_cache import file_cached_function242sage: from tempfile import mkdtemp243sage: from shutil import rmtree244sage: tmpdir = mkdtemp()245sage: @file_cached_function(directory=tmpdir)246....: def f(a, b=True):247....: pass248sage: k = f.get_key(1)249sage: f._FileCachedFunction__save(k, "test")250sage: assert f.cache[k] == "test"251sage: f.clear_cache()252sage: f(1)253'test'254sage: rmtree(tmpdir)255256The above "test" is the file cached value, as f returns None.257"""258self.cache[k] = dat259260(filename, filename_with_path) = self.filename_from_args(k)261try:262self.__create_directory()263except OSError:264return265with bz2.open(filename_with_path, 'wb') as f:266# We force pickle to use protocol version 3 to make267# sure that it works for all Python 3 version268# See269# https://docs.python.org/3/library/pickle.html270if self.pickle_wrapper is not None:271dat = self.pickle_wrapper(dat)272pickle.dump(dat, f, protocol=3)273274def filename_from_args(self, k):275r"""276Constructs a file name of the form func_name_arg1_arg2_arg3.pkl.bz2277278EXAMPLES::279280sage: from admcycles.file_cache import file_cached_function281sage: @file_cached_function(directory="dir")282....: def f(a, b=True):283....: pass284sage: k = f.get_key(1)285sage: f.filename_from_args(k)286('f_1_True.pkl.bz2', 'dir/f_1_True.pkl.bz2')287"""288if self.filename is None:289filename = self.f.__name__290for a in k[0]:291filename += '_' + str(a)292filename += '.pkl.bz2'293else:294filename = self.filename(self.f, k)295filename_with_path = os.path.join(self.directory, filename)296return (filename, filename_with_path)297298def __load_from_file(self, filename_with_path):299r"""300Unplickles the given file and returns the data.301"""302with bz2.open(filename_with_path, 'rb') as file:303if self.unpickle_wrapper is None:304return pickle.load(file)305else:306return self.unpickle_wrapper(pickle.load(file))307308def __download(self, filename, filename_with_path):309r"""310Download the given file from the remote database and stores it311on the file system.312"""313if self.url is None:314raise ValueError('no url provided')315try:316self.__create_directory()317except OSError:318return319complete_url = urllib.parse.urljoin(self.url, filename)320try:321urlretrieve(complete_url, filename_with_path)322except HTTPError:323pass324325def download_all(self):326r"""327Download all files from the remote database.328"""329if self.url is None:330raise ValueError('no url provided')331if self.remote_database_list is None:332raise ValueError('no remote database list provided')333try:334for filename in urlopen(self.remote_database_list):335filename = filename.decode('utf-8').strip()336# Check that the filename does not contain any characters that337# may result in downloading from or saving to an unwanted location.338allowed = set(string.ascii_letters + string.digits + '.' + '_')339if not set(filename) <= allowed:340print("Recived an invalid filename, aborting.")341return342filename_with_path = os.path.join(self.directory, filename)343if not os.path.exists(filename_with_path):344print("Downloading", filename)345self.__download(filename, filename_with_path)346except HTTPError as e:347print("Can not open", self.remote_database_list, e)348349350file_cached_function = decorator_keywords(FileCachedFunction)351352353def ignore_args_key(ignore_args):354r"""355Returns a callable that builds a key from a list of arguments,356but ignores the arguments with the indices supplied by ignore_arguments.357358EXAMPLES::359360sage: from admcycles.file_cache import ignore_args_key361sage: key = ignore_args_key([0, 1])362sage: key("first arg", "second arg", "third arg")363('third arg',)364"""365def key(*args, **invalid_args):366return tuple(arg for i, arg in enumerate(args) if i not in ignore_args)367368return key369370371def ignore_args_filename():372r"""373Returns a callable that builds a file name from the key returned by374ignore_args_key.375376EXAMPLES::377378sage: from admcycles.file_cache import ignore_args_key, ignore_args_filename379sage: key = ignore_args_key([0, 1])380sage: filename = ignore_args_filename()381sage: def test():382....: pass383sage: filename(test, key("first arg", "second arg", "third arg"))384'test_third arg.pkl.bz2'385"""386def filename(f, key):387filename = f.__name__388for a in key:389filename += '_' + str(a)390filename += '.pkl.bz2'391return filename392393return filename394395396def rational_to_py(q):397r"""398Converts a rational number to a pair of python integers.399400EXAMPLES::401402sage: from admcycles.file_cache import rational_to_py403sage: a, b = rational_to_py(QQ(1/2))404sage: a4051406sage: type(a)407<class 'int'>408sage: b4092410sage: type(b)411<class 'int'>412"""413return (int(q.numerator()), int(q.denominator()))414415416def py_to_rational(t):417r"""418Converts a pair of python integers (a,b) into the rational number a/b.419420EXAMPLES::421422sage: from admcycles.file_cache import py_to_rational423sage: q = py_to_rational((1, 2))424sage: q4251/2426sage: type(q)427<class 'sage.rings.rational.Rational'>428"""429return QQ(t[0]) / QQ(t[1])430431432def rational_vectors_to_py(vs):433r"""434Converts a list of vectors over QQ into a list of tuples of pairs of python integers.435436EXAMPLES::437438sage: from admcycles.file_cache import rational_vectors_to_py439sage: v = rational_vectors_to_py([vector(QQ, [1/2, 1])])440sage: v441[((1, 2), (1, 1))]442"""443return [tuple(rational_to_py(a) for a in v) for v in vs]444445446def py_to_rational_vectors(vs):447r"""448Converts a list of tuples of pairs of python integers into a list of sparse vectors over QQ.449450EXAMPLES::451452sage: from admcycles.file_cache import py_to_rational_vectors453sage: v = py_to_rational_vectors([((1, 2), (1, 1))])454sage: v455[(1/2, 1)]456sage: type(v[0])457<class 'sage.modules.free_module_element.FreeModuleElement_generic_sparse'>458"""459return [vector(QQ, (py_to_rational(q) for q in v), sparse=True) for v in vs]460461462