Repository: grantjenks/python-diskcache

Issue:
Finding project links on GitHub requires scrolling
Current package metadata is missing some important links that those towards source code or issue tracker. That is why the presence on https://pypi.org/project/diskcache/ does not expose them on the left sidebar.

While I am not 100%, my impression is that adding the new fields would require replacing the deprecated use of setup.py with setup.cfg (this does not imply PEP-517.)

To give an example of a package using more metadata, look at https://pypi.org/project/mk/ and you will see more links including some exposing number of issues open and pull requires (this is done by pypi automatically). The magic properties that enable this are at https://github.com/pycontribs/mk/blob/main/setup.cfg#L13-L15


Discussion:

Patch:
diff --git a/diskcache/__init__.py b/diskcache/__init__.py
index 428eb30..934a3a7 100644
--- a/diskcache/__init__.py
+++ b/diskcache/__init__.py
@@ -3,7 +3,6 @@ DiskCache API Reference
 =======================
 
 The :doc:`tutorial` provides a helpful walkthrough of most methods.
-
 """
 
 from .core import (
@@ -57,7 +56,7 @@ try:
     from .djangocache import DjangoCache  # noqa
 
     __all__.append('DjangoCache')
-except Exception:  # pylint: disable=broad-except
+except Exception:  # pylint: disable=broad-except  # pragma: no cover
     # Django not installed or not setup so ignore.
     pass
 
diff --git a/diskcache/cli.py b/diskcache/cli.py
index 44bffeb..6a39f60 100644
--- a/diskcache/cli.py
+++ b/diskcache/cli.py
@@ -1,1 +1,1 @@
-"Command line interface to disk cache."
+"""Command line interface to disk cache."""
diff --git a/diskcache/core.py b/diskcache/core.py
index 4d0ae05..d7f707b 100644
--- a/diskcache/core.py
+++ b/diskcache/core.py
@@ -1,5 +1,4 @@
 """Core disk and file backed cache API.
-
 """
 
 import codecs
@@ -22,20 +21,12 @@ import zlib
 
 
 def full_name(func):
-    "Return full name of `func` by adding the module and function name."
+    """Return full name of `func` by adding the module and function name."""
     return func.__module__ + '.' + func.__qualname__
 
 
-try:
-    WindowsError
-except NameError:
-
-    class WindowsError(Exception):
-        "Windows error place-holder on platforms without support."
-
-
 class Constant(tuple):
-    "Pretty display of immutable constant."
+    """Pretty display of immutable constant."""
 
     def __new__(cls, name):
         return tuple.__new__(cls, (name,))
@@ -55,25 +46,25 @@ MODE_TEXT = 3
 MODE_PICKLE = 4
 
 DEFAULT_SETTINGS = {
-    u'statistics': 0,  # False
-    u'tag_index': 0,  # False
-    u'eviction_policy': u'least-recently-stored',
-    u'size_limit': 2 ** 30,  # 1gb
-    u'cull_limit': 10,
-    u'sqlite_auto_vacuum': 1,  # FULL
-    u'sqlite_cache_size': 2 ** 13,  # 8,192 pages
-    u'sqlite_journal_mode': u'wal',
-    u'sqlite_mmap_size': 2 ** 26,  # 64mb
-    u'sqlite_synchronous': 1,  # NORMAL
-    u'disk_min_file_size': 2 ** 15,  # 32kb
-    u'disk_pickle_protocol': pickle.HIGHEST_PROTOCOL,
+    'statistics': 0,  # False
+    'tag_index': 0,  # False
+    'eviction_policy': 'least-recently-stored',
+    'size_limit': 2 ** 30,  # 1gb
+    'cull_limit': 10,
+    'sqlite_auto_vacuum': 1,  # FULL
+    'sqlite_cache_size': 2 ** 13,  # 8,192 pages
+    'sqlite_journal_mode': 'wal',
+    'sqlite_mmap_size': 2 ** 26,  # 64mb
+    'sqlite_synchronous': 1,  # NORMAL
+    'disk_min_file_size': 2 ** 15,  # 32kb
+    'disk_pickle_protocol': pickle.HIGHEST_PROTOCOL,
 }
 
 METADATA = {
-    u'count': 0,
-    u'size': 0,
-    u'hits': 0,
-    u'misses': 0,
+    'count': 0,
+    'size': 0,
+    'hits': 0,
+    'misses': 0,
 }
 
 EVICTION_POLICY = {
@@ -110,7 +101,7 @@ EVICTION_POLICY = {
 
 
 class Disk:
-    "Cache key and value serialization for SQLite database and files."
+    """Cache key and value serialization for SQLite database and files."""
 
     def __init__(self, directory, min_file_size=0, pickle_protocol=0):
         """Initialize disk instance.
@@ -213,29 +204,18 @@ class Disk:
                 return 0, MODE_RAW, None, sqlite3.Binary(value)
             else:
                 filename, full_path = self.filename(key, value)
-
-                with open(full_path, 'xb') as writer:
-                    writer.write(value)
-
+                self._write(full_path, io.BytesIO(value), 'xb')
                 return len(value), MODE_BINARY, filename, None
         elif type_value is str:
             filename, full_path = self.filename(key, value)
-
-            with open(full_path, 'x', encoding='UTF-8') as writer:
-                writer.write(value)
-
+            self._write(full_path, io.StringIO(value), 'x', 'UTF-8')
             size = op.getsize(full_path)
             return size, MODE_TEXT, filename, None
         elif read:
-            size = 0
             reader = ft.partial(value.read, 2 ** 22)
             filename, full_path = self.filename(key, value)
-
-            with open(full_path, 'xb') as writer:
-                for chunk in iter(reader, b''):
-                    size += len(chunk)
-                    writer.write(chunk)
-
+            iterator = iter(reader, b'')
+            size = self._write(full_path, iterator, 'xb')
             return size, MODE_BINARY, filename, None
         else:
             result = pickle.dumps(value, protocol=self.pickle_protocol)
@@ -244,11 +224,33 @@ class Disk:
                 return 0, MODE_PICKLE, None, sqlite3.Binary(result)
             else:
                 filename, full_path = self.filename(key, value)
+                self._write(full_path, io.BytesIO(result), 'xb')
+                return len(result), MODE_PICKLE, filename, None
 
-                with open(full_path, 'xb') as writer:
-                    writer.write(result)
+    def _write(self, full_path, iterator, mode, encoding=None):
+        # pylint: disable=no-self-use
+        full_dir, _ = op.split(full_path)
 
-                return len(result), MODE_PICKLE, filename, None
+        for count in range(1, 11):
+            with cl.suppress(OSError):
+                os.makedirs(full_dir)
+
+            try:
+                # Another cache may have deleted the directory before
+                # the file could be opened.
+                writer = open(full_path, mode, encoding=encoding)
+            except OSError:
+                if count == 10:
+                    # Give up after 10 tries to open the file.
+                    raise
+                continue
+
+            with writer:
+                size = 0
+                for chunk in iterator:
+                    size += len(chunk)
+                    writer.write(chunk)
+                return size
 
     def fetch(self, mode, filename, value, read):
         """Convert fields `mode`, `filename`, and `value` from Cache table to
@@ -259,6 +261,7 @@ class Disk:
         :param value: database value
         :param bool read: when True, return an open file handle
         :return: corresponding Python value
+        :raises: IOError if the value cannot be read
 
         """
         # pylint: disable=no-self-use,unidiomatic-typecheck,consider-using-with
@@ -303,42 +306,34 @@ class Disk:
         hex_name = codecs.encode(os.urandom(16), 'hex').decode('utf-8')
         sub_dir = op.join(hex_name[:2], hex_name[2:4])
         name = hex_name[4:] + '.val'
-        directory = op.join(self._directory, sub_dir)
-
-        try:
-            os.makedirs(directory)
-        except OSError as error:
-            if error.errno != errno.EEXIST:
-                raise
-
         filename = op.join(sub_dir, name)
         full_path = op.join(self._directory, filename)
         return filename, full_path
 
-    def remove(self, filename):
-        """Remove a file given by `filename`.
+    def remove(self, file_path):
+        """Remove a file given by `file_path`.
 
-        This method is cross-thread and cross-process safe. If an "error no
-        entry" occurs, it is suppressed.
+        This method is cross-thread and cross-process safe. If an OSError
+        occurs, it is suppressed.
 
-        :param str filename: relative path to file
+        :param str file_path: relative path to file
 
         """
-        full_path = op.join(self._directory, filename)
+        full_path = op.join(self._directory, file_path)
+        full_dir, _ = op.split(full_path)
 
-        try:
+        # Suppress OSError that may occur if two caches attempt to delete the
+        # same file or directory at the same time.
+
+        with cl.suppress(OSError):
             os.remove(full_path)
-        except WindowsError:
-            pass
-        except OSError as error:
-            if error.errno != errno.ENOENT:
-                # ENOENT may occur if two caches attempt to delete the same
-                # file at the same time.
-                raise
+
+        with cl.suppress(OSError):
+            os.removedirs(full_dir)
 
 
 class JSONDisk(Disk):
-    "Cache key and value using JSON serialization with zlib compression."
+    """Cache key and value using JSON serialization with zlib compression."""
 
     def __init__(self, directory, compress_level=1, **kwargs):
         """Initialize JSON disk instance.
@@ -379,30 +374,33 @@ class JSONDisk(Disk):
 
 
 class Timeout(Exception):
-    "Database timeout expired."
+    """Database timeout expired."""
 
 
 class UnknownFileWarning(UserWarning):
-    "Warning used by Cache.check for unknown files."
+    """Warning used by Cache.check for unknown files."""
 
 
 class EmptyDirWarning(UserWarning):
-    "Warning used by Cache.check for empty directories."
+    """Warning used by Cache.check for empty directories."""
 
 
-def args_to_key(base, args, kwargs, typed):
+def args_to_key(base, args, kwargs, typed, ignore):
     """Create cache key out of function arguments.
 
     :param tuple base: base of key
     :param tuple args: function arguments
     :param dict kwargs: function keyword arguments
     :param bool typed: include types in cache key
+    :param set ignore: positional or keyword args to ignore
     :return: cache key tuple
 
     """
+    args = tuple(arg for index, arg in enumerate(args) if index not in ignore)
     key = base + args
 
     if kwargs:
+        kwargs = {key: val for key, val in kwargs.items() if key not in ignore}
         key += (ENOVAL,)
         sorted_items = sorted(kwargs.items())
 
@@ -419,7 +417,7 @@ def args_to_key(base, args, kwargs, typed):
 
 
 class Cache:
-    "Disk and file backed cache."
+    """Disk and file backed cache."""
 
     def __init__(self, directory=None, timeout=60, disk=Disk, **settings):
         """Initialize cache instance.
@@ -1200,14 +1198,11 @@ class Cache:
 
                 try:
                     value = self._disk.fetch(mode, filename, db_value, read)
-                except IOError as error:
-                    if error.errno == errno.ENOENT:
-                        # Key was deleted before we could retrieve result.
-                        if self.statistics:
-                            sql(cache_miss)
-                        return default
-                    else:
-                        raise
+                except IOError:
+                    # Key was deleted before we could retrieve result.
+                    if self.statistics:
+                        sql(cache_miss)
+                    return default
 
                 if self.statistics:
                     sql(cache_hit)
@@ -1323,12 +1318,9 @@ class Cache:
 
         try:
             value = self._disk.fetch(mode, filename, db_value, False)
-        except IOError as error:
-            if error.errno == errno.ENOENT:
-                # Key was deleted before we could retrieve result.
-                return default
-            else:
-                raise
+        except IOError:
+            # Key was deleted before we could retrieve result.
+            return default
         finally:
             if filename is not None:
                 self._disk.remove(filename)
@@ -1594,11 +1586,9 @@ class Cache:
 
             try:
                 value = self._disk.fetch(mode, name, db_value, False)
-            except IOError as error:
-                if error.errno == errno.ENOENT:
-                    # Key was deleted before we could retrieve result.
-                    continue
-                raise
+            except IOError:
+                # Key was deleted before we could retrieve result.
+                continue
             finally:
                 if name is not None:
                     self._disk.remove(name)
@@ -1710,11 +1700,9 @@ class Cache:
 
             try:
                 value = self._disk.fetch(mode, name, db_value, False)
-            except IOError as error:
-                if error.errno == errno.ENOENT:
-                    # Key was deleted before we could retrieve result.
-                    continue
-                raise
+            except IOError:
+                # Key was deleted before we could retrieve result.
+                continue
             finally:
                 if name is not None:
                     self._disk.remove(name)
@@ -1793,11 +1781,9 @@ class Cache:
 
             try:
                 value = self._disk.fetch(mode, name, db_value, False)
-            except IOError as error:
-                if error.errno == errno.ENOENT:
-                    # Key was deleted before we could retrieve result.
-                    continue
-                raise
+            except IOError:
+                # Key was deleted before we could retrieve result.
+                continue
             break
 
         if expire_time and tag:
@@ -1809,7 +1795,9 @@ class Cache:
         else:
             return key, value
 
-    def memoize(self, name=None, typed=False, expire=None, tag=None):
+    def memoize(
+        self, name=None, typed=False, expire=None, tag=None, ignore=()
+    ):
         """Memoizing cache decorator.
 
         Decorator to wrap callable with memoizing function using cache.
@@ -1868,6 +1856,7 @@ class Cache:
         :param float expire: seconds until arguments expire
             (default None, no expiry)
         :param str tag: text to associate with arguments (default None)
+        :param set ignore: positional or keyword args to ignore (default ())
         :return: callable decorator
 
         """
@@ -1876,12 +1865,12 @@ class Cache:
             raise TypeError('name cannot be callable')
 
         def decorator(func):
-            "Decorator created by memoize() for callable `func`."
+            """Decorator created by memoize() for callable `func`."""
             base = (full_name(func),) if name is None else (name,)
 
             @ft.wraps(func)
             def wrapper(*args, **kwargs):
-                "Wrapper for callable to cache arguments and return values."
+                """Wrapper for callable to cache arguments and return values."""
                 key = wrapper.__cache_key__(*args, **kwargs)
                 result = self.get(key, default=ENOVAL, retry=True)
 
@@ -1893,8 +1882,8 @@ class Cache:
                 return result
 
             def __cache_key__(*args, **kwargs):
-                "Make key for cache given function arguments."
-                return args_to_key(base, args, kwargs, typed)
+                """Make key for cache given function arguments."""
+                return args_to_key(base, args, kwargs, typed, ignore)
 
             wrapper.__cache_key__ = __cache_key__
             return wrapper
@@ -1929,7 +1918,7 @@ class Cache:
 
             rows = sql('PRAGMA integrity_check').fetchall()
 
-            if len(rows) != 1 or rows[0][0] != u'ok':
+            if len(rows) != 1 or rows[0][0] != 'ok':
                 for (message,) in rows:
                     warnings.warn(message)
 
@@ -2308,13 +2297,13 @@ class Cache:
                 yield _disk_get(key, raw)
 
     def __iter__(self):
-        "Iterate keys in cache including expired items."
+        """Iterate keys in cache including expired items."""
         iterator = self._iter()
         next(iterator)
         return iterator
 
     def __reversed__(self):
-        "Reverse iterate keys in cache including expired items."
+        """Reverse iterate keys in cache including expired items."""
         iterator = self._iter(ascending=False)
         next(iterator)
         return iterator
@@ -2372,7 +2361,7 @@ class Cache:
         self.close()
 
     def __len__(self):
-        "Count of items in cache including expired items."
+        """Count of items in cache including expired items."""
         return self.reset('count')
 
     def __getstate__(self):
diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py
index 44f673d..347a613 100644
--- a/diskcache/djangocache.py
+++ b/diskcache/djangocache.py
@@ -1,4 +1,4 @@
-"Django-compatible disk and file backed cache."
+"""Django-compatible disk and file backed cache."""
 
 from functools import wraps
 
@@ -6,7 +6,7 @@ from django.core.cache.backends.base import BaseCache
 
 try:
     from django.core.cache.backends.base import DEFAULT_TIMEOUT
-except ImportError:
+except ImportError:  # pragma: no cover
     # For older versions of Django simply use 300 seconds.
     DEFAULT_TIMEOUT = 300
 
@@ -15,7 +15,7 @@ from .fanout import FanoutCache
 
 
 class DjangoCache(BaseCache):
-    "Django-compatible disk and file backed cache."
+    """Django-compatible disk and file backed cache."""
 
     def __init__(self, directory, params):
         """Initialize DjangoCache instance.
@@ -344,11 +344,11 @@ class DjangoCache(BaseCache):
         return self._cache.cull()
 
     def clear(self):
-        "Remove *all* values from the cache at once."
+        """Remove *all* values from the cache at once."""
         return self._cache.clear()
 
     def close(self, **kwargs):
-        "Close the cache connection."
+        """Close the cache connection."""
         # pylint: disable=unused-argument
         self._cache.close()
 
@@ -373,6 +373,7 @@ class DjangoCache(BaseCache):
         version=None,
         typed=False,
         tag=None,
+        ignore=(),
     ):
         """Memoizing cache decorator.
 
@@ -407,6 +408,7 @@ class DjangoCache(BaseCache):
         :param int version: key version number (default None, cache parameter)
         :param bool typed: cache different types separately (default False)
         :param str tag: text to associate with arguments (default None)
+        :param set ignore: positional or keyword args to ignore (default ())
         :return: callable decorator
 
         """
@@ -415,12 +417,12 @@ class DjangoCache(BaseCache):
             raise TypeError('name cannot be callable')
 
         def decorator(func):
-            "Decorator created by memoize() for callable `func`."
+            """Decorator created by memoize() for callable `func`."""
             base = (full_name(func),) if name is None else (name,)
 
             @wraps(func)
             def wrapper(*args, **kwargs):
-                "Wrapper for callable to cache arguments and return values."
+                """Wrapper for callable to cache arguments and return values."""
                 key = wrapper.__cache_key__(*args, **kwargs)
                 result = self.get(key, ENOVAL, version, retry=True)
 
@@ -444,8 +446,8 @@ class DjangoCache(BaseCache):
                 return result
 
             def __cache_key__(*args, **kwargs):
-                "Make key for cache given function arguments."
-                return args_to_key(base, args, kwargs, typed)
+                """Make key for cache given function arguments."""
+                return args_to_key(base, args, kwargs, typed, ignore)
 
             wrapper.__cache_key__ = __cache_key__
             return wrapper
diff --git a/diskcache/fanout.py b/diskcache/fanout.py
index 99384a0..dc5240c 100644
--- a/diskcache/fanout.py
+++ b/diskcache/fanout.py
@@ -1,4 +1,4 @@
-"Fanout cache automatically shards keys and values."
+"""Fanout cache automatically shards keys and values."""
 
 import contextlib as cl
 import functools
@@ -14,7 +14,7 @@ from .persistent import Deque, Index
 
 
 class FanoutCache:
-    "Cache that shards keys and values."
+    """Cache that shards keys and values."""
 
     def __init__(
         self, directory=None, shards=8, timeout=0.010, disk=Disk, **settings
@@ -512,7 +512,7 @@ class FanoutCache:
         return sum(shard.volume() for shard in self._shards)
 
     def close(self):
-        "Close database connection."
+        """Close database connection."""
         for shard in self._shards:
             shard.close()
         self._caches.clear()
@@ -532,17 +532,17 @@ class FanoutCache:
         self.__init__(*state)
 
     def __iter__(self):
-        "Iterate keys in cache including expired items."
+        """Iterate keys in cache including expired items."""
         iterators = (iter(shard) for shard in self._shards)
         return it.chain.from_iterable(iterators)
 
     def __reversed__(self):
-        "Reverse iterate keys in cache including expired items."
+        """Reverse iterate keys in cache including expired items."""
         iterators = (reversed(shard) for shard in reversed(self._shards))
         return it.chain.from_iterable(iterators)
 
     def __len__(self):
-        "Count of items in cache including expired items."
+        """Count of items in cache including expired items."""
         return sum(len(shard) for shard in self._shards)
 
     def reset(self, key, value=ENOVAL):
diff --git a/diskcache/persistent.py b/diskcache/persistent.py
index 44f9cc7..9b5939b 100644
--- a/diskcache/persistent.py
+++ b/diskcache/persistent.py
@@ -1,5 +1,4 @@
 """Persistent Data Types
-
 """
 
 import operator as op
@@ -18,10 +17,10 @@ from .core import ENOVAL, Cache
 
 
 def _make_compare(seq_op, doc):
-    "Make compare method with Sequence semantics."
+    """Make compare method with Sequence semantics."""
 
     def compare(self, that):
-        "Compare method for deque and sequence."
+        """Compare method for deque and sequence."""
         if not isinstance(that, Sequence):
             return NotImplemented
 
@@ -117,12 +116,12 @@ class Deque(Sequence):
 
     @property
     def cache(self):
-        "Cache used by deque."
+        """Cache used by deque."""
         return self._cache
 
     @property
     def directory(self):
-        "Directory path where deque is stored."
+        """Directory path where deque is stored."""
         return self._cache.directory
 
     def _index(self, index, func):
@@ -699,12 +698,12 @@ class Index(MutableMapping):
 
     @property
     def cache(self):
-        "Cache used by index."
+        """Cache used by index."""
         return self._cache
 
     @property
     def directory(self):
-        "Directory path where items are stored."
+        """Directory path where items are stored."""
         return self._cache.directory
 
     def __getitem__(self, key):
@@ -1097,7 +1096,7 @@ class Index(MutableMapping):
         """
         return not self == other
 
-    def memoize(self, name=None, typed=False):
+    def memoize(self, name=None, typed=False, ignore=()):
         """Memoizing cache decorator.
 
         Decorator to wrap callable with memoizing function using cache.
@@ -1148,10 +1147,11 @@ class Index(MutableMapping):
 
         :param str name: name given for callable (default None, automatic)
         :param bool typed: cache different types separately (default False)
+        :param set ignore: positional or keyword args to ignore (default ())
         :return: callable decorator
 
         """
-        return self._cache.memoize(name, typed)
+        return self._cache.memoize(name, typed, ignore)
 
     @contextmanager
     def transact(self):
diff --git a/diskcache/recipes.py b/diskcache/recipes.py
index 8f7bd32..b5af6dd 100644
--- a/diskcache/recipes.py
+++ b/diskcache/recipes.py
@@ -1,5 +1,4 @@
 """Disk Cache Recipes
-
 """
 
 import functools
@@ -40,7 +39,7 @@ class Averager:
         self._tag = tag
 
     def add(self, value):
-        "Add `value` to average."
+        """Add `value` to average."""
         with self._cache.transact(retry=True):
             total, count = self._cache.get(self._key, default=(0.0, 0))
             total += value
@@ -53,12 +52,12 @@ class Averager:
             )
 
     def get(self):
-        "Get current average or return `None` if count equals zero."
+        """Get current average or return `None` if count equals zero."""
         total, count = self._cache.get(self._key, default=(0.0, 0), retry=True)
         return None if count == 0 else total / count
 
     def pop(self):
-        "Return current average and delete key."
+        """Return current average and delete key."""
         total, count = self._cache.pop(self._key, default=(0.0, 0), retry=True)
         return None if count == 0 else total / count
 
@@ -83,7 +82,7 @@ class Lock:
         self._tag = tag
 
     def acquire(self):
-        "Acquire lock using spin-lock algorithm."
+        """Acquire lock using spin-lock algorithm."""
         while True:
             added = self._cache.add(
                 self._key,
@@ -97,11 +96,11 @@ class Lock:
             time.sleep(0.001)
 
     def release(self):
-        "Release lock by deleting key."
+        """Release lock by deleting key."""
         self._cache.delete(self._key, retry=True)
 
     def locked(self):
-        "Return true if the lock is acquired."
+        """Return true if the lock is acquired."""
         return self._key in self._cache
 
     def __enter__(self):
@@ -137,7 +136,7 @@ class RLock:
         self._tag = tag
 
     def acquire(self):
-        "Acquire lock by incrementing count using spin-lock algorithm."
+        """Acquire lock by incrementing count using spin-lock algorithm."""
         pid = os.getpid()
         tid = threading.get_ident()
         pid_tid = '{}-{}'.format(pid, tid)
@@ -156,7 +155,7 @@ class RLock:
             time.sleep(0.001)
 
     def release(self):
-        "Release lock by decrementing count."
+        """Release lock by decrementing count."""
         pid = os.getpid()
         tid = threading.get_ident()
         pid_tid = '{}-{}'.format(pid, tid)
@@ -206,7 +205,7 @@ class BoundedSemaphore:
         self._tag = tag
 
     def acquire(self):
-        "Acquire semaphore by decrementing value using spin-lock algorithm."
+        """Acquire semaphore by decrementing value using spin-lock algorithm."""
         while True:
             with self._cache.transact(retry=True):
                 value = self._cache.get(self._key, default=self._value)
@@ -221,7 +220,7 @@ class BoundedSemaphore:
             time.sleep(0.001)
 
     def release(self):
-        "Release semaphore by incrementing value."
+        """Release semaphore by incrementing value."""
         with self._cache.transact(retry=True):
             value = self._cache.get(self._key, default=self._value)
             assert self._value > value, 'cannot release un-acquired semaphore'
@@ -338,7 +337,9 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None):
     return decorator
 
 
-def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1):
+def memoize_stampede(
+    cache, expire, name=None, typed=False, tag=None, beta=1, ignore=()
+):
     """Memoizing cache decorator with cache stampede protection.
 
     Cache stampedes are a type of system overload that can occur when parallel
@@ -391,16 +392,17 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1):
     :param str name: name given for callable (default None, automatic)
     :param bool typed: cache different types separately (default False)
     :param str tag: text to associate with arguments (default None)
+    :param set ignore: positional or keyword args to ignore (default ())
     :return: callable decorator
 
     """
     # Caution: Nearly identical code exists in Cache.memoize
     def decorator(func):
-        "Decorator created by memoize call for callable."
+        """Decorator created by memoize call for callable."""
         base = (full_name(func),) if name is None else (name,)
 
         def timer(*args, **kwargs):
-            "Time execution of `func` and return result and time delta."
+            """Time execution of `func` and return result and time delta."""
             start = time.time()
             result = func(*args, **kwargs)
             delta = time.time() - start
@@ -408,7 +410,7 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1):
 
         @functools.wraps(func)
         def wrapper(*args, **kwargs):
-            "Wrapper for callable to cache arguments and return values."
+            """Wrapper for callable to cache arguments and return values."""
             key = wrapper.__cache_key__(*args, **kwargs)
             pair, expire_time = cache.get(
                 key,
@@ -459,8 +461,8 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1):
             return pair[0]
 
         def __cache_key__(*args, **kwargs):
-            "Make key for cache given function arguments."
-            return args_to_key(base, args, kwargs, typed)
+            """Make key for cache given function arguments."""
+            return args_to_key(base, args, kwargs, typed, ignore)
 
         wrapper.__cache_key__ = __cache_key__
         return wrapper
diff --git a/setup.py b/setup.py
index 7f0561c..b29a463 100644
--- a/setup.py
+++ b/setup.py
@@ -29,7 +29,12 @@ setup(
     long_description=readme,
     author='Grant Jenks',
     author_email='contact@grantjenks.com',
-    url='http://www.grantjenks.com/docs/diskcache/',
+    url='http://www.grantjenks.com/docs/diskcache/', 
+    project_urls = {
+        'Documentation':'http://www.grantjenks.com/docs/diskcache/',
+        'Source':'https://github.com/grantjenks/python-diskcache',
+        'Tracker':'https://github.com/grantjenks/python-diskcache/issues',
+        'Funding':'https://gumroad.com/l/diskcache',}
     license='Apache 2.0',
     packages=['diskcache'],
     tests_require=['tox'],
diff --git a/tox.ini b/tox.ini
index 04b7382..104a084 100644
--- a/tox.ini
+++ b/tox.ini
@@ -79,7 +79,7 @@ line_length = 79
 addopts=
     -n auto
     --cov-branch
-    --cov-fail-under=96
+    --cov-fail-under=98
     --cov-report=term-missing
     --cov=diskcache
     --doctest-glob="*.rst"


Test patch:
diff --git a/tests/benchmark_core.py b/tests/benchmark_core.py
index 282ce2a..7d64595 100644
--- a/tests/benchmark_core.py
+++ b/tests/benchmark_core.py
@@ -3,7 +3,6 @@
 $ export PYTHONPATH=/Users/grantj/repos/python-diskcache
 $ python tests/benchmark_core.py -p 1 > tests/timings_core_p1.txt
 $ python tests/benchmark_core.py -p 8 > tests/timings_core_p8.txt
-
 """
 
 import collections as co
diff --git a/tests/benchmark_djangocache.py b/tests/benchmark_djangocache.py
index 9dbbcd7..61a80bf 100644
--- a/tests/benchmark_djangocache.py
+++ b/tests/benchmark_djangocache.py
@@ -2,8 +2,6 @@
 
 $ export PYTHONPATH=/Users/grantj/repos/python-diskcache
 $ python tests/benchmark_djangocache.py > tests/timings_djangocache.txt
-
-
 """
 
 import collections as co
diff --git a/tests/benchmark_glob.py b/tests/benchmark_glob.py
index 9c23104..7f0bf7c 100644
--- a/tests/benchmark_glob.py
+++ b/tests/benchmark_glob.py
@@ -1,4 +1,4 @@
-"Benchmark glob.glob1 as used by django.core.cache.backends.filebased."
+"""Benchmark glob.glob1 as used by django.core.cache.backends.filebased."""
 
 import os
 import os.path as op
diff --git a/tests/benchmark_incr.py b/tests/benchmark_incr.py
index 9c8e2fa..4f758aa 100644
--- a/tests/benchmark_incr.py
+++ b/tests/benchmark_incr.py
@@ -1,5 +1,4 @@
 """Benchmark cache.incr method.
-
 """
 
 import json
@@ -16,7 +15,7 @@ PROCS = 8
 
 
 def worker(num):
-    "Rapidly increment key and time operation."
+    """Rapidly increment key and time operation."""
     time.sleep(0.1)  # Let other workers start.
 
     cache = dc.Cache('tmp')
@@ -33,7 +32,7 @@ def worker(num):
 
 
 def main():
-    "Run workers and print percentile results."
+    """Run workers and print percentile results."""
     shutil.rmtree('tmp', ignore_errors=True)
 
     processes = [
diff --git a/tests/benchmark_kv_store.py b/tests/benchmark_kv_store.py
index e141a36..7015470 100644
--- a/tests/benchmark_kv_store.py
+++ b/tests/benchmark_kv_store.py
@@ -1,7 +1,6 @@
 """Benchmarking Key-Value Stores
 
 $ python -m IPython tests/benchmark_kv_store.py
-
 """
 
 from IPython import get_ipython
diff --git a/tests/issue_109.py b/tests/issue_109.py
index c10a81f..a649c58 100644
--- a/tests/issue_109.py
+++ b/tests/issue_109.py
@@ -1,5 +1,4 @@
 """Benchmark for Issue #109
-
 """
 
 import time
diff --git a/tests/issue_85.py b/tests/issue_85.py
index 723406b..cb8789b 100644
--- a/tests/issue_85.py
+++ b/tests/issue_85.py
@@ -2,7 +2,6 @@
 
 $ export PYTHONPATH=`pwd`
 $ python tests/issue_85.py
-
 """
 
 import collections
diff --git a/tests/plot.py b/tests/plot.py
index 2138659..fcac0bc 100644
--- a/tests/plot.py
+++ b/tests/plot.py
@@ -2,7 +2,6 @@
 
 $ export PYTHONPATH=/Users/grantj/repos/python-diskcache
 $ python tests/plot.py --show tests/timings_core_p1.txt
-
 """
 
 import argparse
@@ -14,7 +13,7 @@ import matplotlib.pyplot as plt
 
 
 def parse_timing(timing, limit):
-    "Parse timing."
+    """Parse timing."""
     if timing.endswith('ms'):
         value = float(timing[:-2]) * 1e-3
     elif timing.endswith('us'):
@@ -26,12 +25,12 @@ def parse_timing(timing, limit):
 
 
 def parse_row(row, line):
-    "Parse row."
+    """Parse row."""
     return [val.strip() for val in row.match(line).groups()]
 
 
 def parse_data(infile):
-    "Parse data from `infile`."
+    """Parse data from `infile`."""
     blocks = re.compile(' '.join(['=' * 9] * 8))
     dashes = re.compile('^-{79}$')
     title = re.compile('^Timings for (.*)$')
@@ -83,7 +82,7 @@ def parse_data(infile):
 
 
 def make_plot(data, action, save=False, show=False, limit=0.005):
-    "Make plot."
+    """Make plot."""
     fig, ax = plt.subplots(figsize=(8, 10))
     colors = ['#ff7f00', '#377eb8', '#4daf4a', '#984ea3', '#e41a1c']
     width = 0.15
diff --git a/tests/plot_early_recompute.py b/tests/plot_early_recompute.py
index e58f580..1508c45 100644
--- a/tests/plot_early_recompute.py
+++ b/tests/plot_early_recompute.py
@@ -1,5 +1,4 @@
 """Early Recomputation Measurements
-
 """
 
 import functools as ft
@@ -61,14 +60,14 @@ def make_repeater(func, total=10, delay=0.01):
 
 
 def frange(start, stop, step=1e-3):
-    "Generator for floating point values from `start` to `stop` by `step`."
+    """Generator for floating point values from `start` to `stop` by `step`."""
     while start < stop:
         yield start
         start += step
 
 
 def plot(option, filename, cache_times, worker_times):
-    "Plot concurrent workers and latency."
+    """Plot concurrent workers and latency."""
     import matplotlib.pyplot as plt
 
     fig, (workers, latency) = plt.subplots(2, sharex=True)
diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py
index 6fd0991..c30fa3f 100644
--- a/tests/stress_test_core.py
+++ b/tests/stress_test_core.py
@@ -1,4 +1,4 @@
-"Stress test diskcache.core.Cache."
+"""Stress test diskcache.core.Cache."""
 
 import collections as co
 import multiprocessing as mp
@@ -292,22 +292,22 @@ def stress_test(
 
 
 def stress_test_lru():
-    "Stress test least-recently-used eviction policy."
+    """Stress test least-recently-used eviction policy."""
     stress_test(eviction_policy=u'least-recently-used')
 
 
 def stress_test_lfu():
-    "Stress test least-frequently-used eviction policy."
+    """Stress test least-frequently-used eviction policy."""
     stress_test(eviction_policy=u'least-frequently-used')
 
 
 def stress_test_none():
-    "Stress test 'none' eviction policy."
+    """Stress test 'none' eviction policy."""
     stress_test(eviction_policy=u'none')
 
 
 def stress_test_mp():
-    "Stress test multiple threads and processes."
+    """Stress test multiple threads and processes."""
     stress_test(processes=4, threads=4)
 
 
diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py
index 58708c9..d3b67e3 100644
--- a/tests/stress_test_fanout.py
+++ b/tests/stress_test_fanout.py
@@ -1,4 +1,4 @@
-"Stress test diskcache.core.Cache."
+"""Stress test diskcache.core.Cache."""
 
 import multiprocessing as mp
 import os
@@ -283,22 +283,22 @@ def stress_test(
 
 
 def stress_test_lru():
-    "Stress test least-recently-used eviction policy."
+    """Stress test least-recently-used eviction policy."""
     stress_test(eviction_policy=u'least-recently-used')
 
 
 def stress_test_lfu():
-    "Stress test least-frequently-used eviction policy."
+    """Stress test least-frequently-used eviction policy."""
     stress_test(eviction_policy=u'least-frequently-used')
 
 
 def stress_test_none():
-    "Stress test 'none' eviction policy."
+    """Stress test 'none' eviction policy."""
     stress_test(eviction_policy=u'none')
 
 
 def stress_test_mp():
-    "Stress test multiple threads and processes."
+    """Stress test multiple threads and processes."""
     stress_test(processes=4, threads=4)
 
 
diff --git a/tests/test_core.py b/tests/test_core.py
index bcc79e1..b3d3d66 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,4 +1,4 @@
-"Test diskcache.core.Cache."
+"""Test diskcache.core.Cache."""
 
 import errno
 import hashlib
@@ -89,6 +89,9 @@ def test_custom_disk():
         for value in values:
             assert cache[value] == value
 
+        for key, value in zip(cache, values):
+            assert key == value
+
     shutil.rmtree(cache.directory, ignore_errors=True)
 
 
@@ -333,26 +336,6 @@ def test_get_expired_slow_path(cache):
     assert cache.get(0) is None
 
 
-def test_get_ioerror_slow_path(cache):
-    cache.reset('eviction_policy', 'least-recently-used')
-    cache.set(0, 0)
-
-    disk = mock.Mock()
-    put = mock.Mock()
-    fetch = mock.Mock()
-
-    disk.put = put
-    put.side_effect = [(0, True)]
-    disk.fetch = fetch
-    io_error = IOError()
-    io_error.errno = errno.EACCES
-    fetch.side_effect = io_error
-
-    with mock.patch.object(cache, '_disk', disk):
-        with pytest.raises(IOError):
-            cache.get(0)
-
-
 def test_pop(cache):
     assert cache.incr('alpha') == 1
     assert cache.pop('alpha') == 1
@@ -396,25 +379,6 @@ def test_pop_ioerror(cache):
         assert cache.pop(0) is None
 
 
-def test_pop_ioerror_eacces(cache):
-    assert cache.set(0, 0)
-
-    disk = mock.Mock()
-    put = mock.Mock()
-    fetch = mock.Mock()
-
-    disk.put = put
-    put.side_effect = [(0, True)]
-    disk.fetch = fetch
-    io_error = IOError()
-    io_error.errno = errno.EACCES
-    fetch.side_effect = io_error
-
-    with mock.patch.object(cache, '_disk', disk):
-        with pytest.raises(IOError):
-            cache.pop(0)
-
-
 def test_delete(cache):
     cache[0] = 0
     assert cache.delete(0)
@@ -591,29 +555,6 @@ def test_least_frequently_used(cache):
     assert len(cache.check()) == 0
 
 
-def test_filename_error(cache):
-    func = mock.Mock(side_effect=OSError(errno.EACCES))
-
-    with mock.patch('os.makedirs', func):
-        with pytest.raises(OSError):
-            cache._disk.filename()
-
-
-def test_remove_error(cache):
-    func = mock.Mock(side_effect=OSError(errno.EACCES))
-
-    try:
-        with mock.patch('os.remove', func):
-            cache._disk.remove('ab/cd/efg.val')
-    except OSError:
-        pass
-    else:
-        if os.name == 'nt':
-            pass  # File delete errors ignored on Windows.
-        else:
-            raise Exception('test_remove_error failed')
-
-
 def test_check(cache):
     blob = b'a' * 2 ** 20
     keys = (0, 1, 1234, 56.78, u'hello', b'world', None)
@@ -1028,44 +969,6 @@ def test_peek_ioerror(cache):
         assert value == 0
 
 
-def test_pull_ioerror_eacces(cache):
-    assert cache.push(0) == 500000000000000
-
-    disk = mock.Mock()
-    put = mock.Mock()
-    fetch = mock.Mock()
-
-    disk.put = put
-    put.side_effect = [(0, True)]
-    disk.fetch = fetch
-    io_error = IOError()
-    io_error.errno = errno.EACCES
-    fetch.side_effect = io_error
-
-    with mock.patch.object(cache, '_disk', disk):
-        with pytest.raises(IOError):
-            cache.pull()
-
-
-def test_peek_ioerror_eacces(cache):
-    assert cache.push(0) == 500000000000000
-
-    disk = mock.Mock()
-    put = mock.Mock()
-    fetch = mock.Mock()
-
-    disk.put = put
-    put.side_effect = [(0, True)]
-    disk.fetch = fetch
-    io_error = IOError()
-    io_error.errno = errno.EACCES
-    fetch.side_effect = io_error
-
-    with mock.patch.object(cache, '_disk', disk):
-        with pytest.raises(IOError):
-            cache.peek()
-
-
 def test_peekitem_extras(cache):
     with pytest.raises(KeyError):
         cache.peekitem()
@@ -1117,27 +1020,6 @@ def test_peekitem_ioerror(cache):
         assert value == 2
 
 
-def test_peekitem_ioerror_eacces(cache):
-    assert cache.set('a', 0)
-    assert cache.set('b', 1)
-    assert cache.set('c', 2)
-
-    disk = mock.Mock()
-    put = mock.Mock()
-    fetch = mock.Mock()
-
-    disk.put = put
-    put.side_effect = [(0, True)]
-    disk.fetch = fetch
-    io_error = IOError()
-    io_error.errno = errno.EACCES
-    fetch.side_effect = io_error
-
-    with mock.patch.object(cache, '_disk', disk):
-        with pytest.raises(IOError):
-            cache.peekitem()
-
-
 def test_iterkeys(cache):
     assert list(cache.iterkeys()) == []
 
@@ -1459,3 +1341,43 @@ def test_memoize(cache):
 
     assert hits2 == (hits1 + count)
     assert misses2 == misses1
+
+
+def test_memoize_kwargs(cache):
+    @cache.memoize(typed=True)
+    def foo(*args, **kwargs):
+        return args, kwargs
+
+    assert foo(1, 2, 3, a=4, b=5) == ((1, 2, 3), {'a': 4, 'b': 5})
+
+
+def test_cleanup_dirs(cache):
+    value = b'\0' * 2 ** 20
+    start_count = len(os.listdir(cache.directory))
+    for i in range(10):
+        cache[i] = value
+    set_count = len(os.listdir(cache.directory))
+    assert set_count > start_count
+    for i in range(10):
+        del cache[i]
+    del_count = len(os.listdir(cache.directory))
+    assert start_count == del_count
+
+
+def test_disk_write_os_error(cache):
+    func = mock.Mock(side_effect=[OSError] * 10)
+    with mock.patch('diskcache.core.open', func):
+        with pytest.raises(OSError):
+            cache[0] = '\0' * 2 ** 20
+
+
+def test_memoize_ignore(cache):
+    @cache.memoize(ignore={1, 'arg1'})
+    def test(*args, **kwargs):
+        return args, kwargs
+
+    cache.stats(enable=True)
+    assert test('a', 'b', 'c', arg0='d', arg1='e', arg2='f')
+    assert test('a', 'w', 'c', arg0='d', arg1='x', arg2='f')
+    assert test('a', 'y', 'c', arg0='d', arg1='z', arg2='f')
+    assert cache.stats() == (2, 1)
diff --git a/tests/test_deque.py b/tests/test_deque.py
index 8113dfe..add7714 100644
--- a/tests/test_deque.py
+++ b/tests/test_deque.py
@@ -1,4 +1,4 @@
-"Test diskcache.persistent.Deque."
+"""Test diskcache.persistent.Deque."""
 
 import pickle
 import shutil
diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py
index 2c216fe..cdaf101 100644
--- a/tests/test_djangocache.py
+++ b/tests/test_djangocache.py
@@ -100,7 +100,7 @@ class UnpicklableType(object):
 
 
 def custom_key_func(key, key_prefix, version):
-    "A customized cache key function"
+    """A customized cache key function"""
     return 'CUSTOM-' + '-'.join([key_prefix, str(version), key])
 
 
@@ -921,7 +921,7 @@ class PicklingSideEffect:
     )
 )
 class DiskCacheTests(BaseCacheTests, TestCase):
-    "Specific test cases for diskcache.DjangoCache."
+    """Specific test cases for diskcache.DjangoCache."""
 
     def setUp(self):
         super().setUp()
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index 8918af3..f212fac 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -1,4 +1,4 @@
-"Test diskcache.fanout.FanoutCache."
+"""Test diskcache.fanout.FanoutCache."""
 
 import collections as co
 import hashlib
diff --git a/tests/test_index.py b/tests/test_index.py
index 27639f7..742daf3 100644
--- a/tests/test_index.py
+++ b/tests/test_index.py
@@ -1,4 +1,4 @@
-"Test diskcache.persistent.Index."
+"""Test diskcache.persistent.Index."""
 
 import pickle
 import shutil
diff --git a/tests/test_recipes.py b/tests/test_recipes.py
index 8d13f2b..ae74459 100644
--- a/tests/test_recipes.py
+++ b/tests/test_recipes.py
@@ -1,4 +1,4 @@
-"Test diskcache.recipes."
+"""Test diskcache.recipes."""
 
 import shutil
 import threading
@@ -28,6 +28,26 @@ def test_averager(cache):
     assert nums.pop() == 9.5
 
 
+def test_lock(cache):
+    state = {'num': 0}
+    lock = dc.Lock(cache, 'demo')
+
+    def worker():
+        state['num'] += 1
+        with lock:
+            assert lock.locked()
+            state['num'] += 1
+            time.sleep(0.1)
+
+    with lock:
+        thread = threading.Thread(target=worker)
+        thread.start()
+        time.sleep(0.1)
+        assert state['num'] == 1
+    thread.join()
+    assert state['num'] == 2
+
+
 def test_rlock(cache):
     state = {'num': 0}
     rlock = dc.RLock(cache, 'demo')
@@ -35,8 +55,9 @@ def test_rlock(cache):
     def worker():
         state['num'] += 1
         with rlock:
-            state['num'] += 1
-            time.sleep(0.1)
+            with rlock:
+                state['num'] += 1
+                time.sleep(0.1)
 
     with rlock:
         thread = threading.Thread(target=worker)
diff --git a/tests/utils.py b/tests/utils.py
index 5b41ce9..38e5d33 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -32,7 +32,7 @@ def secs(value):
 
 
 def run(*args):
-    "Run command, print output, and return output."
+    """Run command, print output, and return output."""
     print('utils$', *args)
     result = sp.check_output(args)
     print(result)
@@ -40,7 +40,7 @@ def run(*args):
 
 
 def mount_ramdisk(size, path):
-    "Mount RAM disk at `path` with `size` in bytes."
+    """Mount RAM disk at `path` with `size` in bytes."""
     sectors = size / 512
 
     os.makedirs(path)
@@ -53,7 +53,7 @@ def mount_ramdisk(size, path):
 
 
 def unmount_ramdisk(dev_path, path):
-    "Unmount RAM disk with `dev_path` and `path`."
+    """Unmount RAM disk with `dev_path` and `path`."""
     run('umount', path)
     run('diskutil', 'eject', dev_path)
     run('rm', '-r', path)