Package: ez-disk-cache
ez-disk-cacheA decorator that provides smart disk-caching for results of long-running or memory-intensive functions.It provides the following features:Management of multiple coexisting cache instances,Automatic cleanup in order to keep user-defined quota,If the decorated function returns an Iterable (List/Tuple/Generator), the values are automatically stored in a shelf and can be retrieved lazily with optional, subsequent discarding. This enables the application to handle sequences of large data chunks that altogether wouldn't fit into memory.Cache instances are organized as sub-folders inside acache root folder. The latter optionally can be defined by the user and gets passed to the decorator. If not provided by the user, the default cache root location ismain_script_location/<name of decorated function>_cache_root. Nevertheless, the user is encouraged to choose aunique cache root folderfor each decorated function, sinceez-disk-cachemight output cryptic warning messages in case two functions share a mutual cache root folder.importtimefromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassConfig(DiskCacheConfig):number:intcolor:str@disk_cache()# <-- Cache root folder goes heredeflong_running_function(config:Config):# <-- Only the config parameter object should be heretime.sleep(2)# Do heavy stuff herereturnLargeObjectThatTakesLongToCreate()long_running_function(config=Config(42,"hello"))# Takes a long timelong_running_function(config=Config(42,"hello"))# Returns immediatelyprint(long_running_function.cache_root_folder)# Prints the location of cache root folderConfig parameter objectWhen calling the decorated function,ez-disk-cachedecides if there is a matching cache instance. This is done via aconfig parameter object, which is passed to the decorated function. It has to be adataclassand inherit fromDiskCacheConfig.Please note: It is strongly recommended that the decorated function accepts the config parameter object as itsonly parameter! Nevertheless, the user may feel free to pass as many arguments to the function as desired ‒ as long as they do not influence the to-be-cached data!Installationpipinstallez-disk-cacheIterables (List/Tuple/Generator)At cache generation ‒in case an Iterable is returned from a decorated function‒ the Iterable is always saved to a shelf file. This keeps the items individually addressable afterwards.Loading a cached Iterable can be done in multiple ways, which is defined by providing theiterable_loading_strategyparameter to theez-disk-cachedecorator:completely-load-to-memoryloads all items to RAM prior to returning them in alistto the application,lazy-load-discardreturns aLazyListto the application. Each time the user accesses an item, it is loaded from disk and discarded right after using. This option might be preferable when working with sequences of large data items, which altogether barely fit in RAM.lazy-load-keepreturns aLazyListto the application. With each access, an item is loaded from disk and cached in RAM. Next accesses to the same item will take place without any delay from accessing disk.@disk_cache(iterable_loading_strategy="<one of the above values>")deflong_running_function(config:Config):# <-- Only config parameter object should be hereobjects=[]foriinrange(1000):time.sleep(3)# Do heavy stuff hereobjects+=[LargeObjectThatTakesLongToCreate(i)]returnobjectsUsage examplesBasic exampleThe following example demonstrates the coexistence of multiple cache instances and their automatic selection.importtimefromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassCarConfig(DiskCacheConfig):wheel_diameter:floatcolor:str@disk_cache("/tmp/car_instances")defconstruct_car(car_config:CarConfig):# <-- Only the config parameter object should be heretime.sleep(5)# Simulate a long process to construct the carreturnf"A fancy{car_config.color}car with wheels of diameter{car_config.wheel_diameter}"# Construct the dark blue car for the first timestart=time.time()car=construct_car(CarConfig(wheel_diameter=35,color="dark blue"))print(car)print(f"Construction took{time.time()-start:.2f}seconds\n")# Construct a red car with the same wheel diameterstart=time.time()car=construct_car(CarConfig(wheel_diameter=35,color="red"))print(car)print(f"Construction took{time.time()-start:.2f}seconds\n")# Now let's see if there is still the dark blue carstart=time.time()car=construct_car(CarConfig(wheel_diameter=35,color="dark blue"))print(car)print(f"Construction took{time.time()-start:.2f}seconds\n")Expected output:A fancy dark blue car with wheels of diameter 35
Construction took 5.01 seconds

A fancy red car with wheels of diameter 35
Construction took 5.01 seconds

A fancy dark blue car with wheels of diameter 35
Construction took 0.00 secondsSince the caches keep existing after the end of a script, the construction of the above cars takes zero time in the second run.Caching generator results and retrieving as LazyListThe following example shows howez-disk-cachecan be used to cache generator function results. This can be particularly helpful when handling huge datasets that won't fit to RAM as a whole.fromdataclassesimportdataclassfromtypingimportListfromez_disk_cacheimportDiskCacheConfig,disk_cache,LazyList@dataclassclassConfig(DiskCacheConfig):n_items:int@disk_cache(iterable_loading_strategy="lazy-load-discard")deflong_running_generator_function(config:Config):# <-- Only the config parameter object should be herefor_inrange(config.n_items):# Heavy workloadyieldDifficultToObtainObject()objects=long_running_generator_function(config=Config(1000))assertisinstance(objects,LazyList)assertlen(objects)==1000foriteminobjects:process(item)Usage within class instancesAs mentioned above, decorated functions are strongly recommended to expect exactly one parameter: theconfig parameter object. This leads to the fact that decorated class member function are better to be declared astaticmethod‒ in order to avoid theselfparameter. The short example below shows how to do that.importtimefromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassConfig(DiskCacheConfig):color:strclassCarDealer:def__init__(self):self.cars=[]forcolorin("red","yellow","blue"):self.cars+=[self._order_car(config=Config(color))]@staticmethod# <-- This lets us avoid the self parameter in the decorated function@disk_cache(cache_root_folder="my/favorite/cache/root/folder")def_order_car(config:Config):# <-- Only the config parameter object should be heretime.sleep(2)# Delivery of a car takes some timereturnf"A fancy{config.color}car"car_dealer=CarDealer()# First instantiation takes a whilecar_dealer=CarDealer()# Second instantiation returns immediatelyprint(car_dealer.cars)Advanced usageQuota for the cache root folderThe cache root folders of the above examples were all unbounded. If, however, one wishes the cache root folder not to exceed certain limits, one might apply the following parameters to the decorator:max_cache_root_size_mbdefines a space limit (in MB) for the cache root folder,max_cache_instancesrestricts the cache root folder to a maximum number of cache instances.As soon as a given cache root folder exceeds one of these limits, old cache instances are being deleted. Old instances are those, that were least-recently used (read).fromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassConfig(DiskCacheConfig):number:int@disk_cache("my/second/favorite/cache/root/folder",max_cache_instances=2)deflong_running_function(config:Config):# <-- Only the config parameter object should be here# Do heavy stuff herereturnLargeObjectThatTakesLongToCreate()long_running_function(config=Config(1))# Takes a long timelong_running_function(config=Config(2))# Takes a long timelong_running_function(config=Config(1))# Finishes quickly. Marks instance 1 as last recently usedlong_running_function(config=Config(3))# Takes a long time. Instance 2 will be deleted accordinglylong_running_function(config=Config(1))# Finishes quicklyManaging cache root foldersA decorated function itself offers a few methods that may be used to manage the underlying cache root folder.fromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassConfig(DiskCacheConfig):number:int@disk_cache("my/third/favorite/cache/root/folder",max_cache_instances=2)deflong_running_function(config:Config):# <-- Only the config parameter object should be here# Do heavy stuff herereturnLargeObjectThatTakesLongToCreate()long_running_function(config=Config(1))# Takes a long timelong_running_function(config=Config(2))# Takes a long timeprint(long_running_function.cache_root_folder)# Prints the location of the underlying cache root folderprint(long_running_function.cache_root_info())# Prints some stats (number of cache instances, space consumption)long_running_function.cache_root_clear()# Clears all cache instances from the cache root folderlong_running_function(config=Config(1))# Takes a long timelong_running_function(config=Config(2))# Takes a long timeMore complex tasks with config objectsAcache instanceis a sub-folder to the cache root folder; it contains the to-be-cached function results along with aserialized YAML fileof the respective parameter config object. Each time a decorated function gets called by the user,ez-disk-cachewalks the pool of available cache instances, deserializes their YAML files and checks if one of them is compatible to the given parameter config object. In the default case,compatiblemeans equality of all parameter fields.To modifyez-disk-cache'sbehavior of how it (de)serializes YAML files and performs compatibility checks, one can override the following config object functions:_to_dict(),_from_dict()and_cache_is_compatible().Selectively matching cache configsThe following example shows how to alter the cache-compatibility behaviour ofez-disk-cache.importtimefromdataclassesimportdataclassfromez_disk_cacheimportDiskCacheConfig,disk_cache@dataclassclassCarConfig(DiskCacheConfig):model:strcolor:str# In this example, we neglect 'color' when searching for compatible cache instances@staticmethoddef_cache_is_compatible(passed_to_decorated_function:"CarConfig",loaded_from_cache:"CarConfig")->bool:"""Return True, if a cache instance is compatible. False if not."""ifpassed_to_decorated_function.model==loaded_from_cache.model:returnTruereturnFalse# At this point, we don't care about 'color'. Everything that matters is 'model'.@disk_cache("/tmp/car_rental")defrent_a_car(car_config:CarConfig):# <-- Only the config parameter object should be heretime.sleep(3)# Renting a car takes some timereturnf"A nice{car_config.color}{car_config.model}, rented for one week!"rent_a_car(CarConfig(model="Tesla Model X",color="red"))# Takes a whilerent_a_car(CarConfig(model="Ford Mustang",color="gold"))# Takes a whilerent_a_car(CarConfig(model="Tesla Model X",color="blue"))# Returns immediately, since we've already rented a TeslaCustom data types within config objectsConfig objects were designed in a way that they work out-of-the-box with basic Python data types (int, float, str, bool). If however, the config contains custom or hierarchical data types, the user must provide custom_to_dictand_from_dictconversion logic.The following example shows how tomanuallyprovide support for custom config fields. Since the following involves lots of boilerplate code, users are encouraged to take a look at thedacitepackage.importtimefromdataclassesimportdataclassfromtypingimportDict,Anyfromez_disk_cacheimportDiskCacheConfig,disk_cacheclassCustomSubType:def__init__(self,a,b):self.a,self.b=a,b@dataclassclassConfig(DiskCacheConfig):some_number:intcustom_parameter:CustomSubTypedef_to_dict(self)->Dict[str,Any]:"""Converts an object to a dict, such that it can be saved to YAML."""dict_={"some_number":self.some_number,"custom_parameter":{"a":self.custom_parameter.a,"b":self.custom_parameter.b}}returndict_@classmethoddef_from_dict(cls,dict_:Dict[str,Any])->"Config":"""Converts a YAML dict to back an object again."""obj=Config(some_number=dict_["some_number"],custom_parameter=CustomSubType(a=dict_["custom_parameter"]["a"],b=dict_["custom_parameter"]["b"]))returnobj@staticmethoddef_cache_is_compatible(passed_to_decorated_function:"Config",loaded_from_cache:"Config")->bool:"""Return True, if a cache instance is compatible. False if not."""ifpassed_to_decorated_function.some_number!=loaded_from_cache.some_number:returnFalseifpassed_to_decorated_function.custom_parameter.a!=loaded_from_cache.custom_parameter.a:returnFalseifpassed_to_decorated_function.custom_parameter.b!=loaded_from_cache.custom_parameter.b:returnFalsereturnTrue@disk_cache("/tmp/complex_config_subtypes_example")deflong_running_function(car_config:Config):# <-- Only the config parameter object should be heretime.sleep(3)# Do heavy stuff herereturnLargeObjectThatTakesLongToCreate()long_running_function(Config(some_number=1,custom_parameter=CustomSubType(2,3)))# Takes longlong_running_function(Config(some_number=1,custom_parameter=CustomSubType(2,3)))# Returns immediately