Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit tests #6

Merged
merged 5 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/unit-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: Build

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:

runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
python-version: [ 3.8, 3.9, '3.10' ]

steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install .
pip install pytest-cov
- name: Test with pytest
run: |
pytest --cov=pecapiku -s tests/unit
- name: Codecov-coverage
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# Pecapiku - a persistent cache pickling utility
[<img src="https://codecov.io/gh/MorrisNein/picapiku/branch/main/graph/badge.svg">](https://app.codecov.io/gh/MorrisNein/pecapiku)
[<img src="https://github.com/MorrisNein/picapiku/workflows/Build/badge.svg?branch=main">](https://github.com/MorrisNein/pecapiku/actions)
[<img src="https://img.shields.io/github/license/MorrisNein/pecapiku">](https://github.com/MorrisNein/pecapiku/blob/main/LICENSE)
[<img src="https://img.shields.io/badge/Telegram-Author-blue.svg">](https://t.me/morrisnein)

# Pecapiku

... a persistent cache pickling utility

Provides a syntax for storing and retrieving the results of a computation on disk using `pickle` library.

> ***Important note!*** The purpose of the utility is not to speed up calculations or to save memory. As the size
of a cache file increases, the access time will raise.
>
> The main purpose is to restart a heavy computational script if something broke in the middle and there is no way to debug it
beforehand.
> of a cache file increases, the access time will raise.
>
> The main purpose is to restart a heavy computational script if something broke in the middle and there is no way to
> debug it
> beforehand.

The two main classes are `CacheDict`, `SingleValueCache`.

Expand All @@ -33,13 +41,15 @@ decorated function.
## Cache File Management

As plain as a day:

``` python
from pecapiku import config

config.get_cache_dir() # Look at the default cache dir
# The result is OS-specific
config.set_cache_dir(...) # Change it to a more preferable directory
```

All cache files will be created inside this directory, if a filename or a relative cache path is provided.
If an absolute path is provided, a pickle file will appear at the path.

Expand Down Expand Up @@ -75,14 +85,15 @@ There are 3 ways of getting a key:
- positional and keyword arguments
- object fields, if this function is a method
2. `inner_key` may be provided in a form of string code expression or a callable.
This expression or callable must return a hashable result that may be used as a dictionary key.
It may use inner function arguments by their corresponding names.
Or it may use `args` and `kwargs` - as the only option for any precompiled non-Python function.
This expression or callable must return a hashable result that may be used as a dictionary key.
It may use inner function arguments by their corresponding names.
Or it may use `args` and `kwargs` - as the only option for any precompiled non-Python function.
3. `outer_key` is a hashable constant to access a value in a `CacheDict`.

## Examples
## Examples

Example 1. CacheDict as a context manager.

Example 1. CacheDict as a context manager.
``` python
import numpy as np
from pecapiku import CacheDict
Expand All @@ -98,7 +109,9 @@ with CacheDict('example_cache_dict.pkl') as cache_dict:
# {'x_T': array([[1, 3],
# [2, 4]])}
```

Example 2. CacheDict as a decorator.

``` python
import numpy as np
from pecapiku import CacheDict
Expand All @@ -115,7 +128,9 @@ cached_mult(a, b)
# array([[ 5, 12],
# [21, 32]])
```

Example 3. SingleValueCache as a decorator.

``` python
import time
from timeit import timeit
Expand All @@ -133,6 +148,7 @@ def a_heavy_function_cached():
print(timeit(a_heavy_function, number=10)) # 10.070
print(timeit(a_heavy_function_cached, number=10)) # 1.015
```

## Installation

`pip install git+https://github.com/MorrisNein/pecapiku`
Expand Down
27 changes: 14 additions & 13 deletions pecapiku/base_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from pathlib import Path
from typing import Any, Callable, Generic, Hashable, Type, TypeVar

from pecapiku.cache_access import COMP_CACHE_FILE_NAME, CacheAccess, _resolve_filepath
from pecapiku.cache_access import CacheAccess
from pecapiku.no_cache import NoCache

logger = logging.getLogger(__file__)

DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
Decorator = Callable[[DecoratedCallable], DecoratedCallable]


class omnimethod(Generic[DecoratedCallable]):
Expand All @@ -30,21 +31,20 @@ def __get__(self, instance, owner) -> DecoratedCallable:

class BaseCache(ABC):
def __init__(self, file_path: os.PathLike | str | None = None, access: CacheAccess = 'rew'):
file_path = file_path or COMP_CACHE_FILE_NAME
file_path = _resolve_filepath(file_path)
self.file_path: Path = file_path
file_path = file_path or self._get_default_file_path()
self.file_path: Path | None = file_path if file_path is None else Path(file_path)
self.access = access

@abstractmethod
def get_cache_val(self, key: Hashable) -> Any:
def _get_cache_val(self, key: Hashable) -> Any:
raise NotImplementedError()

@abstractmethod
def put_cache_val(self, key: Hashable, value: Any):
def _put_cache_val(self, key: Hashable, value: Any):
raise NotImplementedError()

@abstractmethod
def key_func(self, *args, **kwargs) -> Hashable:
def _key_func(self, *args, **kwargs) -> Hashable:
raise NotImplementedError()

def _read_execute_write(self, func, func_args, func_kwargs, access, key_kwargs: dict | None = None) -> Any:
Expand All @@ -53,12 +53,12 @@ def _read_execute_write(self, func, func_args, func_kwargs, access, key_kwargs:
logger.info('Executing cache value, since no access to the cache is provided...')
return func(*func_args, **func_kwargs)

key = self.key_func(func, func_args, func_kwargs, **key_kwargs)
key = self._key_func(func, func_args, func_kwargs, **key_kwargs)

was_read = False
if 'r' in access:
logger.info(f'Getting cache for the key "{key}"...')
val = self.get_cache_val(key)
val = self._get_cache_val(key)
else:
val = NoCache()

Expand All @@ -76,13 +76,13 @@ def _read_execute_write(self, func, func_args, func_kwargs, access, key_kwargs:
val = func(*func_args, **func_kwargs)

if 'w' in access and not was_read and not isinstance(val, NoCache):
self.put_cache_val(key, val)
self._put_cache_val(key, val)
logger.info(f'Writing cache for the key "{key}": {val}...')
return val

@classmethod
@abstractmethod
def _decorate(cls, func: DecoratedCallable, *args, **kwargs) -> DecoratedCallable:
def _decorate(cls, func: DecoratedCallable, *args, **kwargs) -> Decorator | DecoratedCallable:
raise NotImplementedError()

@classmethod
Expand All @@ -93,12 +93,13 @@ def _get_default_file_path(cls):
@omnimethod
def decorate(self: BaseCache | Type[BaseCache],
func: DecoratedCallable,
*,
file_path: os.PathLike | str | None = None,
access: CacheAccess | None = None, *args, **kwargs) -> DecoratedCallable:
access: CacheAccess | None = None, **kwargs) -> Decorator | DecoratedCallable:
if not isinstance(self, BaseCache):
file_path = file_path or self._get_default_file_path()
access = access or 'rew'
else:
file_path = file_path or self.file_path
access = access or self.access
return self._decorate(func, file_path, access, *args, **kwargs)
return self._decorate(func, file_path, access, **kwargs)
32 changes: 18 additions & 14 deletions pecapiku/cache_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from collections import defaultdict
from functools import partial, wraps
from inspect import getcallargs, ismethod, signature
from typing import Any, Callable, Hashable
from typing import Any, Callable, Generic, Hashable

from pecapiku.base_cache import BaseCache, DecoratedCallable, omnimethod
from pecapiku.cache_access import COMP_CACHE_FILE_NAME, CacheAccess, _initialize_cache, update_cache
from pecapiku.base_cache import BaseCache, DecoratedCallable, Decorator, omnimethod
from pecapiku.cache_access import COMP_CACHE_FILE_NAME, CacheAccess, _initialize_cache, _resolve_filepath, update_cache
from pecapiku.hash import get_hash
from pecapiku.no_cache import NoCache

Expand Down Expand Up @@ -58,7 +58,7 @@ def parse_key(callable_or_code: Callable[[Any], Hashable] | str, func: Callable,
return key


class CacheDict(BaseCache):
class CacheDict(BaseCache, Generic[DecoratedCallable]):
""" Decorator/context manager for caching of evaluation results.
Creates a "pickle" file at disk space on a specified path.

Expand Down Expand Up @@ -117,22 +117,23 @@ def __init__(self, file_path: os.PathLike | str | None = None, access: CacheAcce
def __call__(self,
func: DecoratedCallable | None = None,
outer_key: Hashable | None = None,
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable:
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable | Decorator:
return self.decorate(func=func, outer_key=outer_key, inner_key=inner_key)

def get_cache_val(self, key: Hashable) -> Any:
def _get_cache_val(self, key: Hashable) -> Any:
initialize_cache_dict(self.file_path)
return self.cache_dict[key]

def put_cache_val(self, key: Hashable, value: Any) -> None:
def _put_cache_val(self, key: Hashable, value: Any) -> None:
self.cache_dict[key] = value

def key_func(self, func, func_agrs, func_kwargs, inner_key, outer_key) -> Hashable:
def _key_func(self, func, func_agrs, func_kwargs, inner_key, outer_key) -> Hashable:
if outer_key is not None:
key = outer_key
elif inner_key is not None:
key = parse_key(inner_key, func, *func_agrs, **func_kwargs)
else:
hash_objects = [func.__name__, func_agrs, tuple(sorted(func_kwargs.items()))]
hash_objects = [func.__name__, func_agrs, func_kwargs]

if ismethod(func):
hash_objects.insert(0, func.__self__)
Expand All @@ -146,34 +147,36 @@ def _decorate(cls,
file_path: os.PathLike | str | None = None,
access: CacheAccess = 'rew',
outer_key: Hashable | None = None,
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable:
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable | Decorator:
if outer_key is not None and inner_key is not None:
raise ValueError('At most one of (outer key, inner key) can be specified.')

file_path = _resolve_filepath(file_path)

@wraps(func)
def decorated(*args, **kwargs):
instance = cls(file_path, access)
with instance:
val = instance._read_execute_write(func, func_args=args, func_kwargs=kwargs, access=access,
key_kwargs=dict(outer_key=outer_key, inner_key=inner_key))
return val

decorator_return = decorated
if func is None:
decorator_return = partial(
cls._decorate,
file_path=file_path,
access=access,
outer_key=outer_key,
inner_key=inner_key)
else:
decorator_return = decorated
return decorator_return

@omnimethod
def decorate(self, func: DecoratedCallable | None = None,
file_path: os.PathLike | str | None = None,
access: CacheAccess | None = None,
outer_key: Hashable | None = None,
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable:
inner_key: str | Callable[[Any], Hashable] | None = None) -> DecoratedCallable | Decorator:
""" Wraps a function and stores its execution results into a pickled cache dictionary.

Examples:
Expand Down Expand Up @@ -240,6 +243,7 @@ def decorate(self, func: DecoratedCallable | None = None,

def __enter__(self) -> MyDefaultDict:
if 'r' in self.access:
self.file_path = _resolve_filepath(self.file_path)
self.cache_dict = initialize_cache_dict(self.file_path)
else:
self.cache_dict = MyDefaultDict(NoCache)
Expand All @@ -252,7 +256,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
self.cache_dict = None

def get(self, key: None | Hashable) -> NoCache | MyDefaultDict | Any:
file_path = self.file_path
file_path = _resolve_filepath(self.file_path)
cache_dict = _initialize_cache(file_path)
if key is None:
return cache_dict
Expand Down
5 changes: 4 additions & 1 deletion pecapiku/hash.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import json
from typing import Sequence
from typing import Iterable, Sequence


def get_hash(objects: Sequence[object]) -> str:
Expand All @@ -21,6 +21,9 @@ def _json_dumps(obj: object) -> str:


def _json_default(obj: object):
if isinstance(obj, Iterable):
return list(obj)

obj_class = obj.__class__
class_path = '.'.join((obj_class.__module__, obj_class.__name__))
vars_dict = vars(obj) if hasattr(obj, '__dict__') else {}
Expand Down
3 changes: 0 additions & 3 deletions pecapiku/no_cache.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
from __future__ import annotations


class NoCache:
def __bool__(self):
return False
Expand Down
Loading
Loading