Repository: cevans87/atools

Issue:
Allow global override of default_db_path in memoize decorator
If we enable override of the default_db_path, we can make memoize decorators look a little cleaner. So, rather than something like

```
db_path = Path.cwd() / 'foo_db'

@memoize(db=db_path)
def foo(): ...

@memoize(db=db_path)
def bar() ...

def __main__: ...
```

We could have
```
@memoize(db=True)
def foo(): ...

@memoize(db=True)
def bar() ...

def __main__:
    memoize.set_default_db_path(Path.cwd() / 'foo_db')
```

Discussion:

Patch:
diff --git a/atools/_memoize_decorator.py b/atools/_memoize_decorator.py
index 9aeaa97..62b064c 100644
--- a/atools/_memoize_decorator.py
+++ b/atools/_memoize_decorator.py
@@ -16,8 +16,6 @@ from typing import Any, Callable, Hashable, Mapping, Optional, Tuple, Type, Unio
 Decoratee = Union[Callable, Type]
 Keygen = Callable[..., Tuple[Any]]
 
-_default_db_path = Path.home() / '.memoize'
-
 
 class _MemoZeroValue:
     pass
@@ -310,19 +308,7 @@ class _SyncMemoize(_MemoizeBase):
             self.reset_key(key)
 
 
-_Memoize = Union[_AsyncMemoize, _SyncMemoize]
-
-_all_decorators = set()
-
-
-def memoize(
-    _decoratee: Optional[Decoratee] = None,
-    *,
-    db: Union[bool, Path, str] = False,
-    duration: Optional[Union[int, float, timedelta]] = None,
-    keygen: Optional[Keygen] = None,
-    size: Optional[int] = None,
-):
+class _Memoize:
     """Decorates a function call and caches return value for given inputs.
 
     If 'db' is provided, memoized values will be saved to disk and reloaded during initialization.
@@ -492,62 +478,79 @@ def memoize(
             @memoize(db='~/bar_memoize')
             def bar() -> Any: ...
     """
-    if _decoratee is None:
-        return partial(memoize, db=db, duration=duration, keygen=keygen, size=size)
-    
-    if inspect.isclass(_decoratee):
-        assert not db, 'Class memoization not allowed with db.'
 
-        class WrappedMeta(type(_decoratee)):
-            # noinspection PyMethodParameters
-            @memoize(duration=duration, size=size)
-            def __call__(cls, *args, **kwargs):
-                return super().__call__(*args, **kwargs)
+    _default_db_path = Path.home() / '.memoize'
+    _all_decorators = set()
 
-        class Wrapped(_decoratee, metaclass=WrappedMeta):
-            pass
+    @staticmethod
+    def __call__(
+            _decoratee: Optional[Decoratee] = None,
+            *,
+            db: Union[bool, Path, str] = False,
+            duration: Optional[Union[int, float, timedelta]] = None,
+            keygen: Optional[Keygen] = None,
+            size: Optional[int] = None,
+    ):
+        if _decoratee is None:
+            return partial(memoize, db=db, duration=duration, keygen=keygen, size=size)
+
+        if inspect.isclass(_decoratee):
+            assert not db, 'Class memoization not allowed with db.'
+
+            class WrappedMeta(type(_decoratee)):
+                # noinspection PyMethodParameters
+                @memoize(duration=duration, size=size)
+                def __call__(cls, *args, **kwargs):
+                    return super().__call__(*args, **kwargs)
+
+            class Wrapped(_decoratee, metaclass=WrappedMeta):
+                pass
+
+            return type(_decoratee.__name__, (Wrapped,), {'__doc__': _decoratee.__doc__})
+
+        if isinstance(db, (str, Path)):
+            db = connect(f'{db}')
+        elif isinstance(db, bool):
+            if db:
+                db = connect(f'{_Memoize._default_db_path}')
+            else:
+                db = None
+
+        duration = timedelta(seconds=duration) if isinstance(duration, (int, float)) else duration
+        assert (duration is None) or (duration.total_seconds() > 0)
+        assert (size is None) or (size > 0)
+        fn = _decoratee
+        default_kwargs: Mapping[str, Any] = {
+            k: v.default for k, v in inspect.signature(fn).parameters.items()
+        }
+
+        if inspect.iscoroutinefunction(_decoratee):
+            decorator_cls = _AsyncMemoize
+        else:
+            decorator_cls = _SyncMemoize
 
-        return type(_decoratee.__name__, (Wrapped,), {'__doc__': _decoratee.__doc__})
+        # noinspection PyArgumentList
+        decorator = decorator_cls(
+            db=db,
+            default_kwargs=default_kwargs,
+            duration=duration,
+            fn=fn,
+            keygen=keygen,
+            size=size,
+        ).get_decorator()
 
-    if isinstance(db, (str, Path)):
-        db = connect(f'{db}')
-    elif isinstance(db, bool):
-        if db:
-            db = connect(f'{_default_db_path}')
-        else:
-            db = None
-
-    duration = timedelta(seconds=duration) if isinstance(duration, (int, float)) else duration
-    assert (duration is None) or (duration.total_seconds() > 0)
-    assert (size is None) or (size > 0)
-    fn = _decoratee
-    default_kwargs: Mapping[str, Any] = {
-        k: v.default for k, v in inspect.signature(fn).parameters.items()
-    }
-
-    if inspect.iscoroutinefunction(_decoratee):
-        decorator_cls = _AsyncMemoize
-    else:
-        decorator_cls = _SyncMemoize
-
-    # noinspection PyArgumentList
-    decorator = decorator_cls(
-        db=db,
-        default_kwargs=default_kwargs,
-        duration=duration,
-        fn=fn,
-        keygen=keygen,
-        size=size,
-    ).get_decorator()
-
-    _all_decorators.add(decorator)
-    
-    return wraps(_decoratee)(decorator)
+        _Memoize._all_decorators.add(decorator)
 
+        return wraps(_decoratee)(decorator)
 
-def reset_all() -> None:
-    for decorator in _all_decorators:
-        decorator.memoize.reset()
+    @staticmethod
+    def reset_all() -> None:
+        for decorator in _Memoize._all_decorators:
+            decorator.memoize.reset()
+
+    @staticmethod
+    def set_default_db_path(path: Union[Path, str]) -> None:
+        _Memoize._default_db_path = Path(f'{path}')
 
 
-memoize.reset_all = reset_all
+memoize = _Memoize()
diff --git a/setup.py b/setup.py
index b65347c..da3d160 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
 
 setup(
     name='atools',
-    version='0.10.0',
+    version='0.10.1',
     packages=find_packages(),
     python_requires='>=3.6',
     url='https://github.com/cevans87/atools',


Test patch:
diff --git a/test/test_memoize_decorator.py b/test/test_memoize_decorator.py
index 5f13cc8..0cf0a76 100644
--- a/test/test_memoize_decorator.py
+++ b/test/test_memoize_decorator.py
@@ -9,10 +9,19 @@ import pytest
 from sqlite3 import connect, Connection
 from tempfile import NamedTemporaryFile
 from typing import Callable, FrozenSet, Hashable, Iterable, Tuple, Union
-from unittest.mock import call, MagicMock, patch
+from unittest.mock import call, MagicMock, patch, PropertyMock
 from weakref import ref
 
 
+def get_table_len(db) -> int:
+    if isinstance(db, bool):
+        db = connect(f'{memoize._default_db_path}')
+    elif isinstance(db, (Path, str)):
+        db = connect(f'{db}')
+    # noinspection SqlResolve
+    return len(db.execute("SELECT name FROM sqlite_master where type='table'").fetchall())
+
+
 @pytest.fixture
 def async_lock() -> MagicMock:
     with patch.object(test_module, 'AsyncLock', side_effect=None) as async_lock:
@@ -23,8 +32,10 @@ def async_lock() -> MagicMock:
 def db(request) -> Union[bool, Connection, Path, str]:
     with NamedTemporaryFile() as f:
         if request.param is bool:
-            with patch.object(test_module, '_default_db_path') as default_db_path:
-                default_db_path.__str__.return_value = f.name
+            with patch.object(
+                    type(memoize), '_default_db_path', new_callable=PropertyMock
+            ) as default_db_path:
+                default_db_path.return_value = Path(f.name)
                 yield True
         else:
             yield request.param(f.name)
@@ -619,14 +630,6 @@ async def test_keygen_awaits_awaitable_parts() -> None:
 
 def test_db_creates_table_for_each_decorator(db: Union[bool, Connection, Path, str]) -> None:
     
-    def get_table_len(_db) -> int:
-        if isinstance(_db, bool):
-            _db = connect(f'{test_module._default_db_path}')
-        elif isinstance(_db, (Path, str)):
-            _db = connect(f'{_db}')
-        # noinspection SqlResolve
-        return len(_db.execute("SELECT name FROM sqlite_master where type='table'").fetchall())
-
     assert get_table_len(db) == 0
 
     @memoize(db=db)
@@ -848,3 +851,16 @@ def test_reset_call_with_db_resets_call(db: Union[bool, Connection, Path, str])
     for i in range(10):
         foo(i)
     assert body.call_count == 11
+
+
+def test_set_default_db_path_uses_given_path() -> None:
+    with NamedTemporaryFile() as f, patch.object(type(memoize), '_default_db_path'):
+        memoize.set_default_db_path(f.name)
+
+        assert get_table_len(f.name) == 0
+
+        @memoize(db=True)
+        def foo():
+            ...
+
+        assert get_table_len(f.name) == 1