From dc9b97a615610ecd874376b098dd98372b0deb7b Mon Sep 17 00:00:00 2001 From: Sraw Date: Sun, 22 Jul 2018 02:47:48 +0800 Subject: [PATCH 001/329] Add information about when to close instance (#72) Add information about when to close instance --- docs/tutorial.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index f4ff046..679dd17 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -86,6 +86,15 @@ thread that accesses a cache is also responsible for calling :meth:`close >>> cache.close() >>> with Cache('/tmp/mycachedir') as reference: ... pass + +A closed instance will automatically re-open when needed, but re-openning a closed +instance is relatively slow, and as all operations are atomic, so you can safely leave +it open. + + >>> cache.set(b'key') = b'value' + >>> cache.close() + >>> cache.get(b'key') # automatically re-open, but slowly. + 'value' Set an item, get a value, and delete a key using the usual operators: From b9e1408521ab6f1fedb95267b715b5353f1617fb Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 26 Oct 2018 10:02:47 -0700 Subject: [PATCH 002/329] Add test script for issue 85 --- tests/issue_85.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/issue_85.py diff --git a/tests/issue_85.py b/tests/issue_85.py new file mode 100644 index 0000000..455dc75 --- /dev/null +++ b/tests/issue_85.py @@ -0,0 +1,105 @@ +"""Test Script for Issue #85 + +$ export PYTHONPATH=`pwd` +$ python tests/issue_85.py + +""" + +print('REMOVING CACHE DIRECTORY') +import shutil +shutil.rmtree('.cache', ignore_errors=True) + +print('INITIALIZING DJANGO') +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + +import django +django.setup() + +print('RUNNING MULTI-THREADING INIT TEST') +from django.core.cache import cache +def run(): + cache.get('key') + +import threading +threads = [threading.Thread(target=run) for _ in range(50)] +_ = [thread.start() for thread in threads] +_ = [thread.join() for thread in threads] + +print('SQLITE COMPILE OPTIONS') +c = cache._cache._shards[0] +options = c._sql('pragma compile_options').fetchall() +print('\n'.join(val for val, in options)) + +print('CREATING DATA TABLE') +c._con.execute('create table data (x)') +nums = [(num,) for num in range(1000)] +c._con.executemany('insert into data values (?)', nums) +c._timeout = 60 + +commands = { + 'read/write': [ + 'SELECT MAX(x) FROM data', + 'UPDATE data SET x = x + 1', + ], + 'write/read': [ + 'UPDATE data SET x = x + 1', + 'SELECT MAX(x) FROM data', + ], + 'begin/read/write': [ + 'BEGIN', + 'SELECT MAX(x) FROM data', + 'UPDATE data SET x = x + 1', + 'COMMIT', + ], + 'begin/write/read': [ + 'BEGIN', + 'UPDATE data SET x = x + 1', + 'SELECT MAX(x) FROM data', + 'COMMIT', + ], + 'begin immediate/read/write': [ + 'BEGIN IMMEDIATE', + 'SELECT MAX(x) FROM data', + 'UPDATE data SET x = x + 1', + 'COMMIT', + ], + 'begin immediate/write/read': [ + 'BEGIN IMMEDIATE', + 'UPDATE data SET x = x + 1', + 'SELECT MAX(x) FROM data', + 'COMMIT', + ], + 'begin exclusive/read/write': [ + 'BEGIN EXCLUSIVE', + 'SELECT MAX(x) FROM data', + 'UPDATE data SET x = x + 1', + 'COMMIT', + ], + 'begin exclusive/write/read': [ + 'BEGIN EXCLUSIVE', + 'UPDATE data SET x = x + 1', + 'SELECT MAX(x) FROM data', + 'COMMIT', + ], +} + +import collections +errors = collections.deque() + +import sqlite3 + +def run(): + try: + for statement in statements: + c._sql(statement) + except sqlite3.OperationalError: + errors.append(True) + +for key, statements in commands.items(): + print(f'RUNNING {key}') + errors.clear() + threads = [threading.Thread(target=run) for _ in range(100)] + _ = [thread.start() for thread in threads] + _ = [thread.join() for thread in threads] + print('Error count:', len(errors)) From d0d58d2fc3864d7d07ca825b347a9db6c9a7c728 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 26 Oct 2018 10:03:17 -0700 Subject: [PATCH 003/329] Add core.Cache._con for thread-safe connection shortcut --- diskcache/core.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index e449636..05ec0c4 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -537,7 +537,7 @@ def disk(self): @property - def _sql(self): + def _con(self): con = getattr(self._local, 'con', None) if con is None: @@ -561,7 +561,12 @@ def _sql(self): if key.startswith('sqlite_'): self.reset(key, value, update=False) - return con.execute + return con + + + @property + def _sql(self): + return self._con.execute @cl.contextmanager From 71cbe4facbca56bf8fb53ad2c7002f8f7e6bd568 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Oct 2018 22:50:08 -0700 Subject: [PATCH 004/329] Refactor script into functions, add transaction serialization analysis --- tests/issue_85.py | 132 +++++++++++++++++++++++++++++----------------- 1 file changed, 84 insertions(+), 48 deletions(-) diff --git a/tests/issue_85.py b/tests/issue_85.py index 455dc75..a52de97 100644 --- a/tests/issue_85.py +++ b/tests/issue_85.py @@ -5,47 +5,56 @@ """ -print('REMOVING CACHE DIRECTORY') +import collections +import django +import os +import random import shutil -shutil.rmtree('.cache', ignore_errors=True) +import sqlite3 +import threading +import time -print('INITIALIZING DJANGO') -import os -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') -import django -django.setup() +def remove_cache_dir(): + print('REMOVING CACHE DIRECTORY') + shutil.rmtree('.cache', ignore_errors=True) -print('RUNNING MULTI-THREADING INIT TEST') -from django.core.cache import cache -def run(): - cache.get('key') -import threading -threads = [threading.Thread(target=run) for _ in range(50)] -_ = [thread.start() for thread in threads] -_ = [thread.join() for thread in threads] +def init_django(): + global shard + print('INITIALIZING DJANGO') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + django.setup() + from django.core.cache import cache + shard = cache._cache._shards[0] + + +def multi_threading_init_test(): + print('RUNNING MULTI-THREADING INIT TEST') + from django.core.cache import cache + + def run(): + cache.get('key') + + threads = [threading.Thread(target=run) for _ in range(50)] + _ = [thread.start() for thread in threads] + _ = [thread.join() for thread in threads] + + +def show_sqlite_compile_options(): + print('SQLITE COMPILE OPTIONS') + options = shard._sql('pragma compile_options').fetchall() + print('\n'.join(val for val, in options)) -print('SQLITE COMPILE OPTIONS') -c = cache._cache._shards[0] -options = c._sql('pragma compile_options').fetchall() -print('\n'.join(val for val, in options)) -print('CREATING DATA TABLE') -c._con.execute('create table data (x)') -nums = [(num,) for num in range(1000)] -c._con.executemany('insert into data values (?)', nums) -c._timeout = 60 +def create_data_table(): + print('CREATING DATA TABLE') + shard._con.execute('create table data (x)') + nums = [(num,) for num in range(1000)] + shard._con.executemany('insert into data values (?)', nums) + commands = { - 'read/write': [ - 'SELECT MAX(x) FROM data', - 'UPDATE data SET x = x + 1', - ], - 'write/read': [ - 'UPDATE data SET x = x + 1', - 'SELECT MAX(x) FROM data', - ], 'begin/read/write': [ 'BEGIN', 'SELECT MAX(x) FROM data', @@ -84,22 +93,49 @@ def run(): ], } -import collections -errors = collections.deque() -import sqlite3 +values = collections.deque() + -def run(): +def run(statements): + ident = threading.get_ident() try: - for statement in statements: - c._sql(statement) - except sqlite3.OperationalError: - errors.append(True) - -for key, statements in commands.items(): - print(f'RUNNING {key}') - errors.clear() - threads = [threading.Thread(target=run) for _ in range(100)] - _ = [thread.start() for thread in threads] - _ = [thread.join() for thread in threads] - print('Error count:', len(errors)) + for index, statement in enumerate(statements): + if index == (len(statements) - 1): + values.append(('COMMIT', ident)) + time.sleep(random.random() / 10.0) + shard._sql(statement) + if index == 0: + values.append(('BEGIN', ident)) + except sqlite3.OperationalError as exc: + values.append(('ERROR', ident)) + + +def test_transaction_errors(): + for key, statements in commands.items(): + print(f'RUNNING {key}') + values.clear() + threads = [] + for _ in range(100): + thread = threading.Thread(target=run, args=(statements,)) + threads.append(thread) + _ = [thread.start() for thread in threads] + _ = [thread.join() for thread in threads] + errors = [pair for pair in values if pair[0] == 'ERROR'] + begins = [pair for pair in values if pair[0] == 'BEGIN'] + commits = [pair for pair in values if pair[0] == 'COMMIT'] + print('Error count:', len(errors)) + print('Begin count:', len(begins)) + print('Commit count:', len(commits)) + begin_idents = [ident for _, ident in begins] + commit_idents = [ident for _, ident in commits] + print('Serialized:', begin_idents == commit_idents) + + +if __name__ == '__main__': + remove_cache_dir() + init_django() + multi_threading_init_test() + show_sqlite_compile_options() + create_data_table() + test_transaction_errors() From 43029175ed9579e2e8cf89167c2039caa8e1584c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Oct 2018 23:05:26 -0700 Subject: [PATCH 005/329] Fix typo in comment --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index 05ec0c4..02ed192 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1873,7 +1873,7 @@ def reset(self, key, value=ENOVAL, update=True): if key.startswith('sqlite_'): - # 2016-02-17 GrantJ - PRAGMA and autocommit_level=None + # 2016-02-17 GrantJ - PRAGMA and isolation_level=None # don't always play nicely together. Retry setting the # PRAGMA. I think some PRAGMA statements expect to # immediately take an EXCLUSIVE lock on the database. I From f17c945d0a651eda8ce5fb6fe1fe07b1bc5cb21b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 1 Nov 2018 22:12:13 -0700 Subject: [PATCH 006/329] Add _sql_retry attribute and use in __init__ and reset methods --- diskcache/core.py | 97 ++++++++++++++++++++++++++-------------------- tests/test_core.py | 19 --------- 2 files changed, 55 insertions(+), 61 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 02ed192..d7de9a2 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -388,7 +388,7 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): ' and could not be created' % self._directory ) - sql = self._sql + sql = self._sql_retry # Setup Settings table. @@ -569,6 +569,34 @@ def _sql(self): return self._con.execute + @property + def _sql_retry(self): + con = self._con + + # 2018-11-01 GrantJ - Some SQLite builds/versions handle + # the SQLITE_BUSY return value and connection parameter + # "timeout" differently. For a more reliable duration, + # manually retry the statement for 60 seconds. Only used + # by statements which modify the database and do not use + # a transaction (like those in ``__init__`` or ``reset``). + # See Issue #85 for and tests/issue_85.py for more details. + + def _execute_with_retry(statement, *args, **kwargs): + start = time.time() + while True: + try: + return con.execute(statement, *args, **kwargs) + except sqlite3.OperationalError as exc: + if exc.args[0] != 'database is locked': + raise + diff = time.time() - start + if diff > 60: + raise + time.sleep(0.001) + + return _execute_with_retry + + @cl.contextmanager def _transact(self, filename=None): sql = self._sql @@ -1858,51 +1886,36 @@ def reset(self, key, value=ENOVAL, update=True): :raises Timeout: if database timeout expires """ + sql = self._sql_retry + if value is ENOVAL: select = 'SELECT value FROM Settings WHERE key = ?' - (value,), = self._sql(select, (key,)).fetchall() + (value,), = sql(select, (key,)).fetchall() setattr(self, key, value) return value - else: - if update: - with self._transact() as (sql, _): - statement = 'UPDATE Settings SET value = ? WHERE key = ?' - sql(statement, (value, key)) - else: - sql = self._sql - - if key.startswith('sqlite_'): - - # 2016-02-17 GrantJ - PRAGMA and isolation_level=None - # don't always play nicely together. Retry setting the - # PRAGMA. I think some PRAGMA statements expect to - # immediately take an EXCLUSIVE lock on the database. I - # can't find any documentation for this but without the - # retry, stress will intermittently fail with multiple - # processes. - - pause = 0.001 - count = 60000 # 60 / 0.001 - error = sqlite3.OperationalError - pragma = key[7:] - - for _ in range(count): - try: - args = pragma, value - sql('PRAGMA %s = %s' % args).fetchall() - except sqlite3.OperationalError as exc: - error = exc - time.sleep(pause) - else: - break - else: - raise error - del error + if update: + statement = 'UPDATE Settings SET value = ? WHERE key = ?' + sql(statement, (value, key)) - elif key.startswith('disk_'): - attr = key[5:] - setattr(self._disk, attr, value) + if key.startswith('sqlite_'): - setattr(self, key, value) - return value + # 2016-02-17 GrantJ - PRAGMA and isolation_level=None + # don't always play nicely together. Retry setting the + # PRAGMA. I think some PRAGMA statements expect to + # immediately take an EXCLUSIVE lock on the database. I + # can't find any documentation for this but without the + # retry, stress will intermittently fail with multiple + # processes. + + # 2018-11-01 GrantJ - Retry logic moved to + # ``_execute_with_retry`` in ``self._sql_retry``. + + pragma = key[7:] + sql('PRAGMA %s = %s' % (pragma, value)).fetchall() + elif key.startswith('disk_'): + attr = key[5:] + setattr(self._disk, attr, value) + + setattr(self, key, value) + return value diff --git a/tests/test_core.py b/tests/test_core.py index 38c15d5..d58e013 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -177,25 +177,6 @@ def test_init_makedirs(): raise -@setup_cache -def test_pragma(cache): - local = mock.Mock() - con = mock.Mock() - execute = mock.Mock() - cursor = mock.Mock() - fetchall = mock.Mock() - - local.con = con - con.execute = execute - execute.return_value = cursor - cursor.fetchall = fetchall - fetchall.side_effect = [sqlite3.OperationalError, None] - - size = 2 ** 28 - - with mock.patch.object(cache, '_local', local): - assert cache.reset('sqlite_mmap_size', size) == size - @setup_cache @nt.raises(sqlite3.OperationalError) def test_pragma_error(cache): From 6373b641bc2273d9abfb9648b883d61151ead7ac Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 4 Nov 2018 14:55:18 -0800 Subject: [PATCH 007/329] Remove ENOENT check, IOError means get() fails --- diskcache/core.py | 9 +++------ tests/test_core.py | 11 ----------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index d7de9a2..b5273a4 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -975,12 +975,9 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False): 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. - return default - else: - raise + except IOError: + # Key was deleted before we could retrieve result. + return default else: # Slow path, transaction required. cache_hit = ( diff --git a/tests/test_core.py b/tests/test_core.py index d58e013..efeb90b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -280,17 +280,6 @@ def test_get_keyerror4(cache): cache[0] -@nt.raises(IOError) -@setup_cache -def test_get_keyerror5(cache): - func = mock.Mock(side_effect=IOError(errno.EACCES, '')) - - cache[0] = b'abcd' * 2 ** 20 - - with mock.patch('diskcache.core.open', func): - cache[0] - - @setup_cache def test_read(cache): cache.set(0, b'abcd' * 2 ** 20) From d3cb88ada7865dd3550f51ea7010f6e00e19406a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 4 Nov 2018 15:04:47 -0800 Subject: [PATCH 008/329] Change OperationalError exception check for missing arguments --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index b5273a4..1f78a2d 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -587,7 +587,7 @@ def _execute_with_retry(statement, *args, **kwargs): try: return con.execute(statement, *args, **kwargs) except sqlite3.OperationalError as exc: - if exc.args[0] != 'database is locked': + if str(exc) != 'database is locked': raise diff = time.time() - start if diff > 60: From 9ed818969277045ad9503650fafec0a37abb9b38 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 4 Nov 2018 15:24:20 -0800 Subject: [PATCH 009/329] Simplify pragma-setting code before tables exist --- diskcache/core.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 1f78a2d..54d7544 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -409,10 +409,8 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): # Chance to set pragmas before any tables are created. for key, value in sorted(sets.items()): - if not key.startswith('sqlite_'): - continue - - self.reset(key, value, update=False) + if key.startswith('sqlite_'): + self.reset(key, value, update=False) sql('CREATE TABLE IF NOT EXISTS Settings (' ' key TEXT NOT NULL UNIQUE,' From b5f5ef6634cdfd21f88437030ba597593720592c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 4 Nov 2018 15:24:41 -0800 Subject: [PATCH 010/329] Improve comment formatting --- diskcache/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 54d7544..df40908 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -545,9 +545,10 @@ def _con(self): isolation_level=None, ) - # Some SQLite pragmas work on a per-connection basis so query the - # Settings table and reset the pragmas. The Settings table may not - # exist so catch and ignore the OperationalError that may occur. + # Some SQLite pragmas work on a per-connection basis so + # query the Settings table and reset the pragmas. The + # Settings table may not exist so catch and ignore the + # OperationalError that may occur. try: select = 'SELECT key, value FROM Settings' From 32da631ad777156e11e5d190cddcb30489f42d04 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 4 Nov 2018 15:31:50 -0800 Subject: [PATCH 011/329] Add process ID check to support process forking --- diskcache/core.py | 10 ++++++++++ tests/test_core.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index df40908..fb646ef 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -536,6 +536,16 @@ def disk(self): @property def _con(self): + # Check process ID to support process forking. If the process + # ID changes, close the connection and update the process ID. + + local_pid = getattr(self._local, 'pid', None) + pid = os.getpid() + + if local_pid != pid: + self.close() + self._local.pid = pid + con = getattr(self._local, 'con', None) if con is None: diff --git a/tests/test_core.py b/tests/test_core.py index efeb90b..80c54df 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -186,6 +186,7 @@ def test_pragma_error(cache): cursor = mock.Mock() fetchall = mock.Mock() + local.pid = os.getpid() local.con = con con.execute = execute execute.return_value = cursor @@ -324,6 +325,7 @@ def test_set_timeout(cache): con = mock.Mock() execute = mock.Mock() + local.pid = os.getpid() local.con = con con.execute = execute execute.side_effect = sqlite3.OperationalError @@ -942,6 +944,7 @@ def test_add_timeout(cache): con = mock.Mock() execute = mock.Mock() + local.pid = os.getpid() local.con = con con.execute = execute execute.side_effect = sqlite3.OperationalError From 4c4b3305a632655a43892e585ddbbcd99180478e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 5 Nov 2018 08:58:30 -0800 Subject: [PATCH 012/329] Move test_add_concurrent from core to fanout --- tests/test_core.py | 30 ------------------------------ tests/test_fanout.py | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 80c54df..cd56fd6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -907,36 +907,6 @@ def test_add_large_value(cache): cache.check() -def stress_add(cache, limit, results): - total = 0 - for num in range(limit): - if cache.add(num, num): - total += 1 - # Stop one thread from running ahead of others. - time.sleep(0.001) - results.append(total) - - -@setup_cache -def test_add_concurrent(cache): - results = co.deque() - limit = 1000 - - threads = [ - threading.Thread(target=stress_add, args=(cache, limit, results)) - for _ in range(16) - ] - - for thread in threads: - thread.start() - - for thread in threads: - thread.join() - - assert sum(results) == limit - cache.check() - - @setup_cache @nt.raises(dc.Timeout) def test_add_timeout(cache): diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 6d43f95..386bdb4 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -2,6 +2,7 @@ from __future__ import print_function +import collections as co import errno import functools as ft import hashlib @@ -32,13 +33,17 @@ if sys.hexversion < 0x03000000: range = xrange -def setup_cache(func): +def setup_cache(func=None, **options): + if func is None: + return lambda func: setup_cache(func, **options) + @ft.wraps(func) def wrapper(): shutil.rmtree('tmp', ignore_errors=True) - with dc.FanoutCache('tmp') as cache: + with dc.FanoutCache('tmp', **options) as cache: func(cache) shutil.rmtree('tmp', ignore_errors=True) + return wrapper @@ -163,6 +168,36 @@ def test_add_timeout_retry(cache): assert cache.add(0, 0, retry=True) +def stress_add(cache, limit, results): + total = 0 + for num in range(limit): + if cache.add(num, num, retry=True): + total += 1 + # Stop one thread from running ahead of others. + time.sleep(0.001) + results.append(total) + + +@setup_cache(shards=1) +def test_add_concurrent(cache): + results = co.deque() + limit = 1000 + + threads = [ + threading.Thread(target=stress_add, args=(cache, limit, results)) + for _ in range(16) + ] + + for thread in threads: + thread.start() + + for thread in threads: + thread.join() + + assert sum(results) == limit + cache.check() + + @setup_cache def test_incr(cache): cache.incr('key', delta=3) == 3 From 21ae73fca23a833a6ddbdcfa94088a3c4e8c2db7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 5 Nov 2018 09:20:19 -0800 Subject: [PATCH 013/329] Simplify and shorten duration of test_incr_concurrent --- tests/test_fanout.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 386bdb4..e302bed 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -242,27 +242,24 @@ def stress_incr(cache, limit): time.sleep(0.001) -def test_incr_concurrent(): +@setup_cache(shards=1, timeout=0.001) +def test_incr_concurrent(cache): count = 16 - limit = 500 + limit = 50 - with dc.FanoutCache('tmp', timeout=0.001) as cache: - threads = [ - threading.Thread(target=stress_incr, args=(cache, limit)) - for _ in range(count) - ] - - for thread in threads: - thread.start() + threads = [ + threading.Thread(target=stress_incr, args=(cache, limit)) + for _ in range(count) + ] - for thread in threads: - thread.join() + for thread in threads: + thread.start() - with dc.FanoutCache('tmp') as cache: - assert cache.get(b'key') == count * limit - cache.check() + for thread in threads: + thread.join() - shutil.rmtree('tmp', ignore_errors=True) + assert cache.get(b'key') == count * limit + cache.check() @setup_cache From fb3e78568d47802199df8a83aa1d3742ec566711 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 5 Nov 2018 10:18:39 -0800 Subject: [PATCH 014/329] Avoid settings pragmas values that are already set --- diskcache/core.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index fb646ef..3e3bcc0 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1905,6 +1905,7 @@ def reset(self, key, value=ENOVAL, update=True): sql(statement, (value, key)) if key.startswith('sqlite_'): + pragma = key[7:] # 2016-02-17 GrantJ - PRAGMA and isolation_level=None # don't always play nicely together. Retry setting the @@ -1917,8 +1918,14 @@ def reset(self, key, value=ENOVAL, update=True): # 2018-11-01 GrantJ - Retry logic moved to # ``_execute_with_retry`` in ``self._sql_retry``. - pragma = key[7:] - sql('PRAGMA %s = %s' % (pragma, value)).fetchall() + # 2018-11-05 GrantJ - Avoid setting pragma values that + # are already set. Pragma settings like auto_vacuum and + # journal_mode can take a long time or may not work after + # tables have been created. + + (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() + if old_value != value: + sql('PRAGMA %s = %s' % (pragma, value)).fetchall() elif key.startswith('disk_'): attr = key[5:] setattr(self._disk, attr, value) From 76735e03a386ac2741e39755330271f3def99faa Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 5 Nov 2018 10:38:47 -0800 Subject: [PATCH 015/329] Catch Timeout exceptions during stress --- tests/stress_test_core.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index a3e7c8b..bce0d16 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -3,7 +3,7 @@ from __future__ import print_function import collections as co -from diskcache import Cache, UnknownFileWarning, EmptyDirWarning +from diskcache import Cache, UnknownFileWarning, EmptyDirWarning, Timeout import multiprocessing as mp import os import random @@ -124,19 +124,24 @@ def all_ops(): def worker(queue, eviction_policy, processes, threads): - timings = {'get': [], 'set': [], 'delete': []} + timings = co.defaultdict(list) cache = Cache('tmp', eviction_policy=eviction_policy) for index, (action, key, value) in enumerate(iter(queue.get, None)): start = time.time() - if action == 'set': - cache.set(key, value, expire=EXPIRE) - elif action == 'get': - result = cache.get(key) + try: + if action == 'set': + cache.set(key, value, expire=EXPIRE) + elif action == 'get': + result = cache.get(key) + else: + assert action == 'delete' + cache.delete(key) + except Timeout: + miss = True else: - assert action == 'delete' - cache.delete(key) + miss = False stop = time.time() @@ -144,7 +149,10 @@ def worker(queue, eviction_policy, processes, threads): assert result == value if index > WARMUP: - timings[action].append(stop - start) + delta = stop - start + timings[action].append(delta) + if miss: + timings[action + '-miss'].append(delta) queue.put(timings) @@ -179,7 +187,7 @@ def dispatch(num, eviction_policy, processes, threads): stop = time.time() - timings = {'get': [], 'set': [], 'delete': [], 'self': (stop - start)} + timings = co.defaultdict(list) for thread_queue in thread_queues: data = thread_queue.get() @@ -243,7 +251,7 @@ def stress_test(create=True, delete=True, warnings.simplefilter('ignore', category=EmptyDirWarning) cache.check() - timings = {'get': [], 'set': [], 'delete': [], 'self': 0.0} + timings = co.defaultdict(list) for num in range(processes): with open('output-%s.pkl' % num, 'rb') as reader: From c1514c6343801d4ec1a7aecdff7fae9aebb49d21 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 6 Nov 2018 14:19:37 -0800 Subject: [PATCH 016/329] Remove couple of old and lousy multi-threading tests --- tests/test_core.py | 57 ---------------------------------------------- 1 file changed, 57 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index cd56fd6..8348fea 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -810,63 +810,6 @@ def test_tag(cache): assert cache.get(3, tag=True) == (None, b'three') -@setup_cache -def test_multiple_threads(cache): - values = list(range(100)) - - cache[0] = 0 - cache[1] = 1 - cache[2] = 2 - - cache = dc.Cache('tmp') - - def worker(): - sets = list(values) - random.shuffle(sets) - - with dc.Cache('tmp') as thread_cache: - for value in sets: - thread_cache[value] = value - - threads = [threading.Thread(target=worker) for _ in range(10)] - - for thread in threads: - thread.start() - - for thread in threads: - thread.join() - - for value in values: - assert cache[value] == value - - assert len(cache.check()) == 0 - - -@setup_cache -def test_thread_safe(cache): - values = list(range(100)) - - def worker(): - with cache: - sets = list(values) - random.shuffle(sets) - for value in sets: - cache[value] = value - - threads = [threading.Thread(target=worker) for _ in range(10)] - - for thread in threads: - thread.start() - - for thread in threads: - thread.join() - - for value in values: - assert cache[value] == value - - assert len(cache.check()) == 0 - - @setup_cache def test_with(cache): with dc.Cache('tmp') as tmp: From ce0c2ae09357d59609164e47d4fb9ef5540db724 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 6 Nov 2018 14:59:30 -0800 Subject: [PATCH 017/329] Set timeout to 0 during initialization and retry pragma with old value check --- diskcache/core.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 3e3bcc0..b2275d8 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -374,7 +374,7 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): raise ValueError('disk must subclass diskcache.Disk') self._directory = directory - self._timeout = 60 # Use 1 minute timeout for initialization. + self._timeout = 0 # Manually handle retries during initialization. self._local = threading.local() if not op.isdir(directory): @@ -580,7 +580,7 @@ def _sql(self): @property def _sql_retry(self): - con = self._con + sql = self._sql # 2018-11-01 GrantJ - Some SQLite builds/versions handle # the SQLITE_BUSY return value and connection parameter @@ -594,7 +594,7 @@ def _execute_with_retry(statement, *args, **kwargs): start = time.time() while True: try: - return con.execute(statement, *args, **kwargs) + return sql(statement, *args, **kwargs) except sqlite3.OperationalError as exc: if str(exc) != 'database is locked': raise @@ -1892,17 +1892,18 @@ def reset(self, key, value=ENOVAL, update=True): :raises Timeout: if database timeout expires """ - sql = self._sql_retry + sql = self._sql + sql_retry = self._sql_retry if value is ENOVAL: select = 'SELECT value FROM Settings WHERE key = ?' - (value,), = sql(select, (key,)).fetchall() + (value,), = sql_retry(select, (key,)).fetchall() setattr(self, key, value) return value if update: statement = 'UPDATE Settings SET value = ? WHERE key = ?' - sql(statement, (value, key)) + sql_retry(statement, (value, key)) if key.startswith('sqlite_'): pragma = key[7:] @@ -1915,17 +1916,25 @@ def reset(self, key, value=ENOVAL, update=True): # retry, stress will intermittently fail with multiple # processes. - # 2018-11-01 GrantJ - Retry logic moved to - # ``_execute_with_retry`` in ``self._sql_retry``. - # 2018-11-05 GrantJ - Avoid setting pragma values that # are already set. Pragma settings like auto_vacuum and # journal_mode can take a long time or may not work after # tables have been created. - (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() - if old_value != value: - sql('PRAGMA %s = %s' % (pragma, value)).fetchall() + start = time.time() + while True: + try: + (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() + if old_value != value: + sql('PRAGMA %s = %s' % (pragma, value)).fetchall() + break + except sqlite3.OperationalError as exc: + if str(exc) != 'database is locked': + raise + diff = time.time() - start + if diff > 60: + raise + time.sleep(0.001) elif key.startswith('disk_'): attr = key[5:] setattr(self._disk, attr, value) From a43b45700a7527675ab421ee051d010d2e0b1674 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 6 Nov 2018 16:34:08 -0800 Subject: [PATCH 018/329] Change body min-width to 240px for mobile --- docs/_static/custom.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 50b1356..998ebea 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -6,3 +6,7 @@ table { th.head { text-align: center; } + +div.body { + min-width: 240px; +} From 3effaa9b80858fd65fcae1d322b97cafb46a891f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 6 Nov 2018 21:18:56 -0800 Subject: [PATCH 019/329] Change docs around process forking (now supported, yay) --- docs/tutorial.rst | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 679dd17..67823d8 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -64,7 +64,7 @@ Cache ----- The core of :doc:`DiskCache ` is :class:`diskcache.Cache` which -represents a disk and file backed cache. As a Cache it supports a familiar +represents a disk and file backed cache. As a Cache, it supports a familiar Python Mapping interface with additional cache and performance parameters. >>> from diskcache import Cache @@ -77,23 +77,24 @@ Cache objects may also reference the same directory from separate threads or processes. In this way, they are also process-safe and support cross-process communication. -When created, Cache objects open and maintain a file handle. As such, they do -not survive process forking but they may be serialized using Pickle. Each -thread that accesses a cache is also responsible for calling :meth:`close -` on the cache. You can use a Cache reference in a -`with` statement to safeguard calling :meth:`close `. +Cache objects open and maintain one or more file handles. But unlike files, all +Cache operations are atomic and Cache objects support process-forking and may +be serialized using Pickle. Each thread that accesses a cache should also call +:meth:`close ` on the cache. Cache objects can be used +in a `with` statement to safeguard calling :meth:`close +`. >>> cache.close() >>> with Cache('/tmp/mycachedir') as reference: ... pass -A closed instance will automatically re-open when needed, but re-openning a closed -instance is relatively slow, and as all operations are atomic, so you can safely leave -it open. +Closed Cache objects will automatically re-open when accessed. But opening Cache +objects is relatively slow, and since all operations are atomic, you can safely +leave Cache objects open. >>> cache.set(b'key') = b'value' >>> cache.close() - >>> cache.get(b'key') # automatically re-open, but slowly. + >>> cache.get(b'key') # Automatically opens, but slower. 'value' Set an item, get a value, and delete a key using the usual operators: From 6511693b4a534203d1f39828a9854f80a3d04451 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 7 Nov 2018 16:36:11 -0800 Subject: [PATCH 020/329] Change flask-cache link to flask-caching --- docs/development.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.rst b/docs/development.rst index a37c3a7..404c1da 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -29,7 +29,7 @@ Requests for Contributions #. Backend Compatibility - #. `Flask-Cache `_ + #. `Flask-Caching `_ #. `Beaker `_ #. `dogpile.cache `_ From 1d466a38b3be39a822cc2e7c2f1fc286dc454f4f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 7 Nov 2018 16:39:09 -0800 Subject: [PATCH 021/329] Update disk_min_file_size in docs to 32 kb --- docs/api.rst | 2 +- docs/tutorial.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 90f15f9..660d094 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -58,7 +58,7 @@ Read the :ref:`Settings tutorial ` for details. pragma. * `sqlite_synchronous` (int) default 1, "NORMAL" - SQLite synchronous pragma. - * `disk_min_file_size` (int, in bytes) default one kilobyte - values with + * `disk_min_file_size` (int, in bytes) default 32 kilobytes - values with greater size are stored in files. * `disk_pickle_protocol` (int) default highest Pickle protocol - the Pickle protocol to use for data types that are not natively supported. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 67823d8..15d8cd2 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -529,7 +529,7 @@ are updated lazily. Prefer idioms like :meth:`len >>> cache.size_limit 4000000000 >>> cache.disk_min_file_size - 1024 + 32768 >>> cache.reset('cull_limit', 0) # Disable automatic evictions. 0 >>> cache.set(b'key', 1.234) @@ -544,7 +544,7 @@ these may be specified when initializing the :ref:`Cache `. Changing these values will update the unprefixed attribute on the :class:`Disk ` object. -* `disk_min_file_size`, default one kilobyte. The minimum size to store a value +* `disk_min_file_size`, default 32 kilobytes. The minimum size to store a value in a file. * `disk_pickle_protocol`, default highest Pickle protocol. The Pickle protocol to use for data types that are not natively supported. From ea45793c8ca1b51d0c41878921eac63f5db9d6d9 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 7 Nov 2018 17:05:45 -0800 Subject: [PATCH 022/329] Add more docs about FanoutCache retry behavior for Mapping operators --- diskcache/fanout.py | 15 +++++++++++++++ docs/tutorial.rst | 20 ++++++++++++-------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 565537a..cfa2ccf 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -58,6 +58,9 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param value: value for item :param float expire: seconds until the key expires @@ -84,6 +87,8 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): def __setitem__(self, key, value): """Set `key` and `value` item in cache. + Calls :func:`FanoutCache.set` internally with `retry` set to `True`. + :param key: key for item :param value: value for item @@ -190,6 +195,9 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, retry=False): """Retrieve value from cache. If `key` is missing, return `default`. + If database timeout occurs then returns `default` unless `retry` is set + to `True` (default `False`). + :param key: key for item :param default: return value if key is missing (default None) :param bool read: if True, return file handle to value @@ -220,6 +228,8 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, def __getitem__(self, key): """Return corresponding value for `key` from cache. + Calls :func:`FanoutCache.get` internally with `retry` set to `True`. + :param key: key for item :return: value for item :raises KeyError: if key is not found @@ -295,6 +305,9 @@ def delete(self, key, retry=False): Missing keys are ignored. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param bool retry: retry if database timeout expires (default False) :return: True if item was deleted @@ -318,6 +331,8 @@ def delete(self, key, retry=False): def __delitem__(self, key): """Delete corresponding item for `key` from cache. + Calls :func:`FanoutCache.delete` internally with `retry` set to `True`. + :param key: key for item :raises KeyError: if key is not found diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 15d8cd2..a9e94f8 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -312,18 +312,22 @@ suggested. This will depend on your scenario. The default value is 8. Another parameter, `timeout`, sets a limit on how long to wait for database transactions. Transactions are used for every operation that writes to the -database. The `timeout` parameter is also present on -:class:`diskcache.Cache`. When a :exc:`diskcache.Timeout` error occurs in -:class:`Cache ` methods, the exception is raised to the -caller. In contrast, :class:`FanoutCache ` catches +database. When the timeout expires, a :exc:`diskcache.Timeout` error is raised +internally. This `timeout` parameter is also present on +:class:`diskcache.Cache`. When a :exc:`Timeout ` error +occurs in :class:`Cache ` methods, the exception is raised to +the caller. In contrast, :class:`FanoutCache ` catches timeout errors and aborts the operation. As a result, :meth:`set ` and :meth:`delete ` methods may silently fail. Most methods that handle :exc:`Timeout ` exceptions also include a `retry` keyword parameter -(default ``False``) to automatically repeat attempts that -timeout. :class:`FanoutCache ` will never raise a -:exc:`Timeout ` exception. The default `timeout` is 0.010 -(10 milliseconds). +(default ``False``) to automatically repeat attempts that timeout. The Mapping +interface operators: :meth:`cache[key] `, +:meth:`cache[key] = value `, and :meth:`del +cache[key] ` automatically retry operations +when :exc:`Timeout ` errors occur. :class:`FanoutCache +` will never raise a :exc:`Timeout ` +exception. The default `timeout` is 0.010 (10 milliseconds). >>> from diskcache import FanoutCache >>> cache = FanoutCache('/tmp/mycachedir', shards=4, timeout=1) From a08bf815964a134a87cfa18b47ac72a1e7a88766 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 8 Nov 2018 16:44:50 -0800 Subject: [PATCH 023/329] Bump version to 3.1.0 --- diskcache/__init__.py | 4 ++-- docs/tutorial.rst | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index ca58f14..6ac1d4d 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -29,8 +29,8 @@ __title__ = 'diskcache' -__version__ = '3.0.6' -__build__ = 0x030006 +__version__ = '3.1.0' +__build__ = 0x030100 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2018 Grant Jenks' diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a9e94f8..b6b245d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -87,10 +87,10 @@ in a `with` statement to safeguard calling :meth:`close >>> cache.close() >>> with Cache('/tmp/mycachedir') as reference: ... pass - -Closed Cache objects will automatically re-open when accessed. But opening Cache -objects is relatively slow, and since all operations are atomic, you can safely -leave Cache objects open. + +Closed Cache objects will automatically re-open when accessed. But opening +Cache objects is relatively slow, and since all operations are atomic, you can +safely leave Cache objects open. >>> cache.set(b'key') = b'value' >>> cache.close() From 769b36aa5d6e259e4926332a1d0a37406f964d83 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 16 Nov 2018 11:34:07 -0800 Subject: [PATCH 024/329] Catch ValueError when unpacking old pragma value. --- diskcache/core.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index b2275d8..af60a1d 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1924,8 +1924,12 @@ def reset(self, key, value=ENOVAL, update=True): start = time.time() while True: try: - (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() - if old_value != value: + try: + (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() + update = old_value != value + except ValueError: + update = True + if update: sql('PRAGMA %s = %s' % (pragma, value)).fetchall() break except sqlite3.OperationalError as exc: From 2e3af05c435023213353fa6084252059b03acd92 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 22 Nov 2018 10:44:52 -0800 Subject: [PATCH 025/329] Bump version to 3.1.1 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 6ac1d4d..26532cc 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -29,8 +29,8 @@ __title__ = 'diskcache' -__version__ = '3.1.0' -__build__ = 0x030100 +__version__ = '3.1.1' +__build__ = 0x030101 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2018 Grant Jenks' From 05cac6ab51dc723200a158ecb7b58e0ddcd662ce Mon Sep 17 00:00:00 2001 From: Mateusz Konieczny Date: Wed, 28 Nov 2018 20:09:43 +0100 Subject: [PATCH 026/329] fix syntax error in tutorial (#95) fix syntax error in tutorial (#95) --- docs/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index b6b245d..7ca2e41 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -92,7 +92,7 @@ Closed Cache objects will automatically re-open when accessed. But opening Cache objects is relatively slow, and since all operations are atomic, you can safely leave Cache objects open. - >>> cache.set(b'key') = b'value' + >>> cache.set(b'key', b'value') >>> cache.close() >>> cache.get(b'key') # Automatically opens, but slower. 'value' From 50a80fe1662e80b14be1f28db34d22a6ab86aa04 Mon Sep 17 00:00:00 2001 From: George Tantiras Date: Thu, 17 Jan 2019 01:11:14 +0200 Subject: [PATCH 027/329] Clarified timout settings for DjangoCache (#98) --- docs/tutorial.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 7ca2e41..d594731 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -390,7 +390,8 @@ DjangoCache 'BACKEND': 'diskcache.DjangoCache', 'LOCATION': '/path/to/cache/directory', 'SHARDS': 4, - 'DATABASE_TIMEOUT': 1.0, + 'DATABASE_TIMEOUT': 1.0, # Timeout for each DjangoCache database transaction + 'TIMEOUT': 300, # Default timeout for each key 'OPTIONS': { 'size_limit': 2 ** 32 # 4 gigabytes }, From 2774689c60bac3ebd06246943bca2014779ee2c6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 16 Jan 2019 15:22:45 -0800 Subject: [PATCH 028/329] Improve comments around DjangoCache settings example --- docs/tutorial.rst | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index d594731..faf7e28 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -389,19 +389,23 @@ DjangoCache 'default': { 'BACKEND': 'diskcache.DjangoCache', 'LOCATION': '/path/to/cache/directory', - 'SHARDS': 4, - 'DATABASE_TIMEOUT': 1.0, # Timeout for each DjangoCache database transaction - 'TIMEOUT': 300, # Default timeout for each key + 'TIMEOUT': 300, + # ^-- Django setting for default timeout of each key. + 'SHARDS': 8, + 'DATABASE_TIMEOUT': 0.010, # 10 milliseconds + # ^-- Timeout for each DjangoCache database transaction. 'OPTIONS': { - 'size_limit': 2 ** 32 # 4 gigabytes + 'size_limit': 2 ** 30 # 1 gigabyte }, }, } As with :class:`FanoutCache ` above, these settings -create a Django-compatible cache with four shards and a one second timeout. You -can pass further settings via the ``OPTIONS`` mapping as shown in the Django -documentation. :class:`DjangoCache ` will never raise a +create a Django-compatible cache with eight shards and a 10ms timeout. You can +pass further settings via the ``OPTIONS`` mapping as shown in the Django +documentation. Only the ``BACKEND`` and ``LOCATION`` keys are necessary in the +above example. The other keys simply display their default +value. :class:`DjangoCache ` will never raise a :exc:`Timeout ` exception. But unlike :class:`FanoutCache `, the keyword parameter `retry` defaults to ``True`` for :class:`DjangoCache ` methods. From 445afdc53c73c65a929bb8b780de2f2e63cc2775 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 12 Mar 2019 22:23:43 -0700 Subject: [PATCH 029/329] Fix Cache.incr docstring --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index af60a1d..0064c79 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -865,7 +865,7 @@ def incr(self, key, delta=1, default=0): :param key: key for item :param int delta: amount to increment (default 1) - :param int default: value if key is missing (default None) + :param int default: value if key is missing (default 0) :return: new value for item :raises KeyError: if key is not found and default is None :raises Timeout: if database timeout expires From 5458892614dd9c1a844c1daa797c24872d1eee28 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 13 Mar 2019 22:01:25 -0700 Subject: [PATCH 030/329] Remove __del__ methods from Deque and Index (error prone, what's the point?) --- diskcache/persistent.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 499c350..d17cd2f 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -618,10 +618,6 @@ def rotate(self, steps=1): self.append(value) - def __del__(self): - self._cache.close() - - __hash__ = None @@ -1342,7 +1338,3 @@ def __repr__(self): """ name = type(self).__name__ return '{0}({1!r})'.format(name, self.directory) - - - def __del__(self): - self._cache.close() From bf407f1fd3cbaf62d2c15783866ab70def731ecd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 16 Nov 2018 12:05:19 -0800 Subject: [PATCH 031/329] Fix abc imports for Python 3.7 warning. --- diskcache/persistent.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index d17cd2f..1bd0388 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -5,18 +5,32 @@ import operator as op import sys -from collections import MutableMapping, OrderedDict, Sequence -from collections import KeysView, ValuesView, ItemsView +from collections import OrderedDict from itertools import islice from shutil import rmtree from tempfile import mkdtemp from .core import BytesType, Cache, ENOVAL, TextType, Timeout +############################################################################ +# BEGIN Python 2/3 Shims +############################################################################ + +try: + from collections.abc import MutableMapping, Sequence + from collections.abc import KeysView, ValuesView, ItemsView +except ImportError: + from collections import MutableMapping, Sequence + from collections import KeysView, ValuesView, ItemsView + if sys.hexversion < 0x03000000: from itertools import izip as zip # pylint: disable=redefined-builtin,no-name-in-module,ungrouped-imports range = xrange # pylint: disable=redefined-builtin,invalid-name,undefined-variable +############################################################################ +# END Python 2/3 Shims +############################################################################ + def _make_compare(seq_op, doc): "Make compare method with Sequence semantics." From f921f6981a177a3af5580ab0b2069de9007b1c90 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 22 Nov 2018 09:30:13 -0800 Subject: [PATCH 032/329] Update references to Python 3.7 --- .travis.yml | 1 + README.rst | 2 +- docs/development.rst | 3 ++- docs/index.rst | 4 ++-- setup.py | 1 + 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index c72311a..6b50cb2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.4" - "3.5" - "3.6" + - "3.7" - "pypy" install: - pip install -r requirements.txt diff --git a/README.rst b/README.rst index f6cf18c..341ccba 100644 --- a/README.rst +++ b/README.rst @@ -63,7 +63,7 @@ Features - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction - Developed on Python 2.7 -- Tested on CPython 2.7, 3.4, 3.5, 3.6 and PyPy +- Tested on CPython 2.7, 3.4, 3.5, 3.6, 3.7 and PyPy - Tested on Linux, Mac OS X, and Windows - Tested using Travis CI and AppVeyor CI diff --git a/docs/development.rst b/docs/development.rst index 404c1da..6d16d44 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -65,12 +65,13 @@ counterparts are necessary for some benchmarks. Testing ------- -:doc:`DiskCache ` currently tests against four versions of Python: +:doc:`DiskCache ` currently tests against five versions of Python: * CPython 2.7 * CPython 3.4 * CPython 3.5 * CPython 3.6 +* CPython 3.7 * PyPy2 Testing uses `tox `_. If you don't want to diff --git a/docs/index.rst b/docs/index.rst index ed0357a..68576df 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,8 +62,8 @@ Features - Thread-safe and process-safe - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction -- Developed on Python 2.7 -- Tested on CPython 2.7, 3.4, 3.5, 3.6 and PyPy +- Developed on Python 3.7 +- Tested on CPython 2.7, 3.4, 3.5, 3.6, 3.7 and PyPy - Tested on Linux, Mac OS X, and Windows - Tested using Travis CI and AppVeyor CI diff --git a/setup.py b/setup.py index 0f00554..56150dc 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ def run_tests(self): 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ), From 936a086919499089b3f0d1ea595d227c12f7820d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 22 Nov 2018 09:35:03 -0800 Subject: [PATCH 033/329] Update travis/reqs --- .travis.yml | 23 ++++++++++++----------- requirements.txt | 6 +++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6b50cb2..7d20288 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,13 @@ +sudo: false language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "pypy" -install: - - pip install -r requirements.txt -script: - - nosetests -v +install: pip install -r requirements.txt +script: nosetests -v +matrix: + include: + - python: 2.7 + - python: 3.4 + - python: 3.5 + - python: 3.6 + - python: 3.7 + dist: xenial + - python: pypy diff --git a/requirements.txt b/requirements.txt index f59ec75..34bc2f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -mock==1.3.0 -nose==1.3.7 -django>=1.11,<1.12 +mock +nose +django<2 From d66035887ff5ada119f216c110216f794d2f6897 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 22 Nov 2018 10:13:12 -0800 Subject: [PATCH 034/329] Start transition from nose to pytest --- .travis.yml | 13 +++++++++++-- MANIFEST.in | 2 +- appveyor.yml | 6 ++++-- requirements.txt | 13 ++++++++++++- setup.py | 19 +++++++++---------- tox.ini | 32 +++++++++++++++++++++++++++----- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7d20288..2c15404 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,22 @@ sudo: false language: python -install: pip install -r requirements.txt -script: nosetests -v +install: pip install tox +script: tox matrix: include: - python: 2.7 + env: TOXENV=py27 - python: 3.4 + env: TOXENV=py34 - python: 3.5 + env: TOXENV=py35 - python: 3.6 + env: TOXENV=py36 - python: 3.7 dist: xenial + env: TOXENV=py37 - python: pypy + env: TOXENV=pypy + - python: 3.7 + dist: xenial + env: TOXENV=lint diff --git a/MANIFEST.in b/MANIFEST.in index 645a28c..0c73842 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include README.rst LICENSE requirements.txt +include README.rst LICENSE diff --git a/appveyor.yml b/appveyor.yml index 4afd5c8..dc376f1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,17 +6,19 @@ environment: - PYTHON: "C:\\Python34" - PYTHON: "C:\\Python35" - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python34-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" + - PYTHON: "C:\\Python37-x64" install: - - "%PYTHON%\\python.exe -m pip install nose mock django==1.11.12" + - "%PYTHON%\\python.exe -m pip install tox" build: off test_script: - - "%PYTHON%\\python.exe -m nose -v" + - "%PYTHON%\\python.exe -m tox -e py" diff --git a/requirements.txt b/requirements.txt index 34bc2f0..bfbdb82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,14 @@ +coverage +django<2 +doc8 +gj mock nose -django<2 +pylint +pytest +pytest-cov +pytest-django +sphinx +tox +twine +wheel diff --git a/setup.py b/setup.py index 56150dc..64da622 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,9 @@ -import io -from setuptools import setup, find_packages +from setuptools import setup from setuptools.command.test import test as TestCommand -import sys import diskcache + class Tox(TestCommand): def finalize_options(self): TestCommand.finalize_options(self) @@ -13,24 +12,24 @@ def finalize_options(self): def run_tests(self): import tox errno = tox.cmdline(self.test_args) - sys.exit(errno) + exit(errno) + -with io.open('README.rst', encoding='UTF-8') as reader: +with open('README.rst') as reader: readme = reader.read() setup( - name='diskcache', + name=diskcache.__title__, version=diskcache.__version__, - description='Disk and file backed cache.', + description='Disk Cache -- Disk and file backed persistent cache.', long_description=readme, author='Grant Jenks', author_email='contact@grantjenks.com', url='http://www.grantjenks.com/docs/diskcache/', - packages=find_packages(exclude=('tests', 'docs')), - package_data={'': ['LICENSE', 'README.rst']}, + license='Apache 2.0', + packages=['diskcache'], tests_require=['tox'], cmdclass={'test': Tox}, - license='Apache 2.0', install_requires=[], classifiers=( 'Development Status :: 5 - Production/Stable', diff --git a/tox.ini b/tox.ini index 9b13a3d..9013024 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,29 @@ [tox] -envlist=py27,py34,py35,py36 +envlist=py27,py34,py35,py36,py37,pypy,lint +skip_missing_interpreters=True + [testenv] -deps=nose - mock - django>=1.11,<1.12 -commands=nosetests +deps= + django<2 + mock + pytest + pytest-django +commands=python -m pytest +setenv = + DJANGO_SETTINGS_MODULE=tests.settings + PYTHONPATH={toxinidir}:{toxinidir}/tests + +[pytest] +addopts= + --doctest-modules + --doctest-glob "*.rst" + --ignore tests/benchmark_core.py + --ignore tests/benchmark_djangocache.py + --ignore tests/benchmark_glob.py + --ignore tests/plot.py +norecursedirs=site-packages +testpaths=docs diskcache tests + +[testenv:lint] +deps=pylint +commands=pylint diskcache From e2c5c464c77507d7234b108cccf1535099d35d83 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 21:58:42 -0700 Subject: [PATCH 035/329] Change Django version to 1.11.* --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfbdb82..c1cc167 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ coverage -django<2 +django==1.11.* doc8 gj mock From 4dbd291964673606b33ec52bb6bdd5855936d77d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 22:30:03 -0700 Subject: [PATCH 036/329] Remove old talk files --- tests/talk/benchmark.py | 35 --------- tests/talk/crawler.py | 52 ------------- tests/talk/manage.py | 22 ------ tests/talk/talk/__init__.py | 0 tests/talk/talk/settings.py | 141 ------------------------------------ tests/talk/talk/urls.py | 25 ------- tests/talk/talk/views.py | 25 ------- tests/talk/talk/wsgi.py | 16 ---- 8 files changed, 316 deletions(-) delete mode 100644 tests/talk/benchmark.py delete mode 100644 tests/talk/crawler.py delete mode 100755 tests/talk/manage.py delete mode 100644 tests/talk/talk/__init__.py delete mode 100644 tests/talk/talk/settings.py delete mode 100644 tests/talk/talk/urls.py delete mode 100644 tests/talk/talk/views.py delete mode 100644 tests/talk/talk/wsgi.py diff --git a/tests/talk/benchmark.py b/tests/talk/benchmark.py deleted file mode 100644 index e115162..0000000 --- a/tests/talk/benchmark.py +++ /dev/null @@ -1,35 +0,0 @@ -import random, requests, signal, time, threading - -signal.signal(signal.SIGINT, lambda signum, frame: exit()) - - -count = 0 - -def monitor(): - global count - while True: - time.sleep(1) - print(f"{'*' * (count // 8)}") - count = 0 - -thread = threading.Thread(target=monitor) -thread.daemon = True -thread.start() - - -# Histogram of expovariate values: -# value | count -# ----- | ----- -# 64 | ************************************************************* -# 127 | ******************************** -# 191 | *************** -# 254 | ****** -# 318 | *** -# 382 | ** -# 445 | * -# 509 | - -while True: - value = int(random.expovariate(1) * 100) - response = requests.get(f'http://127.0.0.1:8000/echo/{value}') - count += 1 diff --git a/tests/talk/crawler.py b/tests/talk/crawler.py deleted file mode 100644 index fc36154..0000000 --- a/tests/talk/crawler.py +++ /dev/null @@ -1,52 +0,0 @@ -import bs4, requests, signal, urllib.parse - -signal.signal(signal.SIGINT, lambda signum, frame: exit()) - -root='http://127.0.0.1:8000/' - - -def get(url): - "Get url and return response text." - print(url) - response = requests.get(url) - return response.text - - -def parse(url, text): - "Parse url with given text and yield links." - soup = bs4.BeautifulSoup(text, 'lxml') - - for anchor in soup.find_all('a', href=True): - full_url = urllib.parse.urljoin(url, anchor['href']) - href, _ = urllib.parse.urldefrag(full_url) - - if href.startswith(root): - yield href - - -from collections import deque - -def crawl(): - "Crawl root url." - urls = deque([root]) - results = dict() - - while True: - try: - url = urls.popleft() - except IndexError: - break - - if url in results: - continue - - text = get(url) - - for link in parse(url, text): - urls.append(link) - - results[url] = text - - -if __name__ == '__main__': - crawl() diff --git a/tests/talk/manage.py b/tests/talk/manage.py deleted file mode 100755 index 0c84391..0000000 --- a/tests/talk/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "talk.settings") - try: - from django.core.management import execute_from_command_line - except ImportError: - # The above import may fail for some other reason. Ensure that the - # issue is really that Django is missing to avoid masking other - # exceptions on Python 2. - try: - import django - except ImportError: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) - raise - execute_from_command_line(sys.argv) diff --git a/tests/talk/talk/__init__.py b/tests/talk/talk/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/talk/talk/settings.py b/tests/talk/talk/settings.py deleted file mode 100644 index aa4165f..0000000 --- a/tests/talk/talk/settings.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Django settings for talk project. - -Generated by 'django-admin startproject' using Django 1.10.6. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.10/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '_lzt+2b46g)@x%set-4u7j-vjw-_%sq4xdco990z(l4o2$^_)*' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False - -ALLOWED_HOSTS = ['127.0.0.1'] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'talk.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'talk.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.10/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - - -# Password validation -# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/1.10/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.10/howto/static-files/ - -STATIC_URL = '/static/' - - -CACHES = { - 'filebased': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': '/tmp/filebased', - 'OPTIONS': { - 'MAX_ENTRIES': 1000, - } - }, - 'memcached': { - 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', - 'LOCATION': [ - '127.0.0.1:11211', - ], - }, - 'diskcache': { - 'BACKEND': 'diskcache.DjangoCache', - 'LOCATION': '/tmp/diskcache', - } -} diff --git a/tests/talk/talk/urls.py b/tests/talk/talk/urls.py deleted file mode 100644 index ac79390..0000000 --- a/tests/talk/talk/urls.py +++ /dev/null @@ -1,25 +0,0 @@ -"""talk URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.10/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.conf.urls import url, include - 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) -""" -from django.conf.urls import url -from django.contrib import admin - -from . import views - -urlpatterns = [ - url(r'^echo/(?P.*)$', views.echo), - url(r'^$', views.index), - url(r'^crawl/(?P.*)$', views.crawl), -] diff --git a/tests/talk/talk/views.py b/tests/talk/talk/views.py deleted file mode 100644 index 675b31e..0000000 --- a/tests/talk/talk/views.py +++ /dev/null @@ -1,25 +0,0 @@ -import random, time - -from django.http import HttpResponse -from django.views.decorators.cache import cache_page - -# @cache_page(3600, cache='filebased') -# @cache_page(3600, cache='memcached') -# @cache_page(3600, cache='diskcache') -def echo(request, value): - time.sleep(0.1) - return HttpResponse(value, content_type='text/plain') - - -def index(request): - return HttpResponse('0') - - -def crawl(request, value): - time.sleep(random.random()) - value = int(value) - random.seed(value) - nums = random.sample(range(100), 5) - link = '{0}
' - links = ''.join(link.format(num) for num in nums) - return HttpResponse('{}'.format(links)) diff --git a/tests/talk/talk/wsgi.py b/tests/talk/talk/wsgi.py deleted file mode 100644 index a636d03..0000000 --- a/tests/talk/talk/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for talk project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "talk.settings") - -application = get_wsgi_application() From 7ca8e375691c6ffc56b0816558edebf489153316 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 22:30:29 -0700 Subject: [PATCH 037/329] Exclude built docs --- .gitignore | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 69c7ee7..7af3b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,16 @@ # virutalenv directories /env*/ -# coverage files +# test files/directories .coverage - -# setup sdist, test and upload directories +.pytest_cache/ /.tox/ + +# setup and upload directories /build/ /dist/ /diskcache.egg-info/ +/docs/_build/ +# macOS metadata .DS_Store From 02f9902b84fafa6bc825ce1335da8d632c462258 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 22:30:41 -0700 Subject: [PATCH 038/329] Update pylint --- .pylintrc | 494 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 259 insertions(+), 235 deletions(-) diff --git a/.pylintrc b/.pylintrc index 7e716ec..39d96b0 100644 --- a/.pylintrc +++ b/.pylintrc @@ -2,7 +2,7 @@ # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code +# run arbitrary code. extension-pkg-whitelist= # Add files or directories to the blacklist. They should be base names, not @@ -17,9 +17,15 @@ ignore-patterns= # pygtk.require(). #init-hook= -# Use multiple processes to speed up Pylint. +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. jobs=1 +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= @@ -31,7 +37,7 @@ persistent=yes #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages +# user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the @@ -42,18 +48,18 @@ unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to +# file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". disable=print-statement, parameter-unpacking, unpacking-in-except, @@ -67,11 +73,11 @@ disable=print-statement, raw-checker-failed, bad-inline-option, locally-disabled, - locally-enabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, + use-symbolic-message-instead, apply-builtin, basestring-builtin, buffer-builtin, @@ -127,9 +133,12 @@ disable=print-statement, dict-items-not-iterating, dict-keys-not-iterating, dict-values-not-iterating, - no-else-return, - inconsistent-return-statements, - not-callable + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -148,15 +157,15 @@ enable=c-extension-no-member evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details +# used to format the message information. See doc for all details. #msg-template= # Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg +# and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. output-format=text -# Tells whether to display a full report or only the messages +# Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. @@ -166,120 +175,134 @@ score=yes [REFACTORING] # Maximum number of nested blocks for function / method body -max-nested-blocks=6 +max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. -never-returning-functions=optparse.Values,sys.exit +never-returning-functions=sys.exit -[BASIC] +[LOGGING] -# Naming style matching correct argument names -argument-naming-style=snake_case +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=old -# Regular expression matching correct argument names. Overrides argument- -# naming-style -#argument-rgx= +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging -# Naming style matching correct attribute names -attr-naming-style=snake_case -# Regular expression matching correct attribute names. Overrides attr-naming- -# style -#attr-rgx= +[SPELLING] -# Bad variable names which should always be refused, separated by a comma -bad-names=foo, - bar, - baz, - toto, - tutu, - tata +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 -# Naming style matching correct class attribute names -class-attribute-naming-style=any +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style -#class-attribute-rgx= +# List of comma separated words that should not be checked. +spelling-ignore-words= -# Naming style matching correct class names -class-naming-style=PascalCase +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= -# Regular expression matching correct class names. Overrides class-naming-style -#class-rgx= +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no -# Naming style matching correct constant names -const-naming-style=UPPER_CASE -# Regular expression matching correct constant names. Overrides const-naming- -# style -#const-rgx= +[MISCELLANEOUS] -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO -# Naming style matching correct function names -function-naming-style=snake_case -# Regular expression matching correct function names. Overrides function- -# naming-style -#function-rgx= +[TYPECHECK] -# Good variable names which should always be accepted, separated by a comma -good-names=i, - j, - k, - ex, - Run, - _ +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= -# Naming style matching correct inline iteration names -inlinevar-naming-style=any +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style -#inlinevar-rgx= +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes -# Naming style matching correct method names -method-naming-style=snake_case +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes -# Regular expression matching correct method names. Overrides method-naming- -# style -#method-rgx= +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local -# Naming style matching correct module names -module-naming-style=snake_case +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= -# Regular expression matching correct module names. Overrides module-naming- -# style -#module-rgx= +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty -# Naming style matching correct variable names -variable-naming-style=snake_case +[VARIABLES] -# Regular expression matching correct variable names. Overrides variable- -# naming-style -#variable-rgx= +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io [FORMAT] @@ -290,7 +313,7 @@ expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ -# Number of spaces of indent required inside a hanging or continued line. +# Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 @@ -300,8 +323,8 @@ indent-string=' ' # Maximum number of characters on a single line. max-line-length=100 -# Maximum number of lines in a module -max-module-lines=2000 +# Maximum number of lines in a module. +max-module-lines=1000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. @@ -319,21 +342,6 @@ single-line-class-stmt=no single-line-if-stmt=no -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - [SIMILARITIES] # Ignore comments when computing similarities. @@ -349,106 +357,154 @@ ignore-imports=no min-similarity-lines=4 -[SPELLING] +[BASIC] -# Limits count of emitted suggestions for spelling mistakes -max-spelling-suggestions=4 +# Naming style matching correct argument names. +argument-naming-style=snake_case -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= -# List of comma separated words that should not be checked. -spelling-ignore-words= +# Naming style matching correct attribute names. +attr-naming-style=snake_case -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata +# Naming style matching correct class attribute names. +class-attribute-naming-style=any -[TYPECHECK] +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager +# Naming style matching correct class names. +class-naming-style=PascalCase -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members=eviction_policy, - statistics, - count, - size, - cull_limit +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= +# Naming style matching correct function names. +function-naming-style=snake_case -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any -[VARIABLES] +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= +# Naming style matching correct method names. +method-naming-style=snake_case -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb +# Naming style matching correct module names. +module-naming-style=snake_case -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= -# Tells whether we should check for unused import in __init__ files. -init-import=no +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant [CLASSES] @@ -470,77 +526,45 @@ exclude-protected=_asdict, valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs +valid-metaclass-classmethod-first-arg=cls [DESIGN] -# Maximum number of arguments for function / method -max-args=8 +# Maximum number of arguments for function / method. +max-args=5 # Maximum number of attributes for a class (see R0902). max-attributes=7 -# Maximum number of boolean expressions in a if statement +# Maximum number of boolean expressions in an if statement. max-bool-expr=5 -# Maximum number of branch for function / method body -max-branches=20 +# Maximum number of branch for function / method body. +max-branches=12 -# Maximum number of locals for function / method body -max-locals=30 +# Maximum number of locals for function / method body. +max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). -max-public-methods=25 +max-public-methods=20 -# Maximum number of return / yield for function / method body -max-returns=8 +# Maximum number of return / yield for function / method body. +max-returns=6 -# Maximum number of statements in function / method body -max-statements=60 +# Maximum number of statements in function / method body. +max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception From 63e3d77c35bbc928e201ce9c1bde0303680ba8fe Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 22:31:01 -0700 Subject: [PATCH 039/329] Update dependencies for testing --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 9013024..a0588f1 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,7 @@ skip_missing_interpreters=True [testenv] deps= - django<2 - mock + django==1.11.* pytest pytest-django commands=python -m pytest From 85a79b0149d20ffa728a65e1c2d0992c43308b7c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 23:04:45 -0700 Subject: [PATCH 040/329] Add nose back as dep (for Python 2.7) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index a0588f1..7864ed4 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ skip_missing_interpreters=True [testenv] deps= django==1.11.* + mock pytest pytest-django commands=python -m pytest From 452d956898190685be18131a2ab0f1a0a1010d0c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 18 Mar 2019 23:05:17 -0700 Subject: [PATCH 041/329] Update tests for pytest framework --- tests/test_core.py | 202 +++++++++++++++------------------------------ 1 file changed, 65 insertions(+), 137 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 8348fea..76b5568 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,9 +9,9 @@ import io import json import mock -import nose.tools as nt import os import os.path as op +import pytest import random import shutil import sqlite3 @@ -31,23 +31,19 @@ import diskcache import diskcache as dc -warnings.simplefilter('error') -warnings.simplefilter('ignore', category=dc.EmptyDirWarning) +pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning) if sys.hexversion < 0x03000000: range = xrange -def setup_cache(func): - @ft.wraps(func) - def wrapper(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.Cache('tmp') as cache: - func(cache) - shutil.rmtree('tmp', ignore_errors=True) - return wrapper +@pytest.fixture +def cache(): + shutil.rmtree('tmp', ignore_errors=True) + with dc.Cache('tmp') as cache: + yield cache + shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_init(cache): for key, value in dc.DEFAULT_SETTINGS.items(): assert getattr(cache, key) == value @@ -93,10 +89,10 @@ def test_disk_reset(): shutil.rmtree('tmp', ignore_errors=True) -@nt.raises(ValueError) def test_disk_valueerror(): - with dc.Cache('tmp', disk=dc.Disk('tmp')) as cache: - pass + with pytest.raises(ValueError): + with dc.Cache('tmp', disk=dc.Disk('tmp')) as cache: + pass class JSONDisk(diskcache.Disk): @@ -164,21 +160,19 @@ def test_custom_filename_disk(): shutil.rmtree('tmp', ignore_errors=True) -@nt.raises(EnvironmentError) def test_init_makedirs(): shutil.rmtree('tmp', ignore_errors=True) makedirs = mock.Mock(side_effect=OSError(errno.EACCES)) - try: - with mock.patch('os.makedirs', makedirs): - cache = dc.Cache('tmp') - except EnvironmentError: - shutil.rmtree('tmp') - raise + with pytest.raises(EnvironmentError): + try: + with mock.patch('os.makedirs', makedirs): + cache = dc.Cache('tmp') + except EnvironmentError: + shutil.rmtree('tmp') + raise -@setup_cache -@nt.raises(sqlite3.OperationalError) def test_pragma_error(cache): local = mock.Mock() con = mock.Mock() @@ -197,10 +191,10 @@ def test_pragma_error(cache): with mock.patch('time.sleep', lambda num: 0): with mock.patch.object(cache, '_local', local): - cache.reset('sqlite_mmap_size', size) + with pytest.raises(sqlite3.OperationalError): + cache.reset('sqlite_mmap_size', size) -@setup_cache def test_close_error(cache): class LocalTest(object): def __init__(self): @@ -216,7 +210,6 @@ def __getattr__(self, name): cache.close() -@setup_cache def test_getsetdel(cache): values = [ (None, False), @@ -263,14 +256,11 @@ def test_getsetdel(cache): cache.check() -@nt.raises(KeyError) -@setup_cache def test_get_keyerror1(cache): - cache[0] + with pytest.raises(KeyError): + cache[0] -@nt.raises(IOError, KeyError) -@setup_cache def test_get_keyerror4(cache): func = mock.Mock(side_effect=IOError(errno.ENOENT, '')) @@ -278,24 +268,22 @@ def test_get_keyerror4(cache): cache[0] = b'abcd' * 2 ** 20 with mock.patch('diskcache.core.open', func): - cache[0] + with pytest.raises((IOError, KeyError, OSError)): + cache[0] -@setup_cache def test_read(cache): cache.set(0, b'abcd' * 2 ** 20) with cache.read(0) as reader: assert reader is not None -@nt.raises(KeyError) -@setup_cache def test_read_keyerror(cache): - with cache.read(0) as reader: - pass + with pytest.raises(KeyError): + with cache.read(0) as reader: + pass -@setup_cache def test_set_twice(cache): large_value = b'abcd' * 2 ** 20 @@ -318,8 +306,6 @@ def test_set_twice(cache): cache.check() -@setup_cache -@nt.raises(dc.Timeout) def test_set_timeout(cache): local = mock.Mock() con = mock.Mock() @@ -330,20 +316,19 @@ def test_set_timeout(cache): con.execute = execute execute.side_effect = sqlite3.OperationalError - try: - with mock.patch.object(cache, '_local', local): - cache.set('a', 'b' * 2 ** 20) - finally: - cache.check() + with pytest.raises(dc.Timeout): + try: + with mock.patch.object(cache, '_local', local): + cache.set('a', 'b' * 2 ** 20) + finally: + cache.check() -@setup_cache def test_raw(cache): assert cache.set(0, io.BytesIO(b'abcd'), read=True) assert cache[0] == b'abcd' -@setup_cache def test_get(cache): assert cache.get(0) is None assert cache.get(1, 'dne') == 'dne' @@ -356,14 +341,12 @@ def test_get(cache): assert cache.get(0, tag=True) == (0, u'number') assert cache.get(0, expire_time=True, tag=True) == (0, None, u'number') -@setup_cache def test_get_expired_fast_path(cache): assert cache.set(0, 0, expire=0.001) time.sleep(0.01) assert cache.get(0) is None -@setup_cache def test_get_ioerror_fast_path(cache): assert cache.set(0, 0) @@ -382,7 +365,6 @@ def test_get_ioerror_fast_path(cache): assert cache.get(0) is None -@setup_cache def test_get_expired_slow_path(cache): cache.stats(enable=True) cache.reset('eviction_policy', 'least-recently-used') @@ -391,8 +373,6 @@ def test_get_expired_slow_path(cache): assert cache.get(0) is None -@setup_cache -@nt.raises(IOError) def test_get_ioerror_slow_path(cache): cache.reset('eviction_policy', 'least-recently-used') cache.set(0, 0) @@ -409,10 +389,10 @@ def test_get_ioerror_slow_path(cache): fetch.side_effect = io_error with mock.patch.object(cache, '_disk', disk): - cache.get(0) + with pytest.raises(IOError): + cache.get(0) -@setup_cache def test_pop(cache): assert cache.incr('alpha') == 1 assert cache.pop('alpha') == 1 @@ -438,7 +418,6 @@ def test_pop(cache): assert cache.pop('epsilon') == '0' * 2 ** 20 -@setup_cache def test_pop_ioerror(cache): assert cache.set(0, 0) @@ -457,8 +436,6 @@ def test_pop_ioerror(cache): assert cache.pop(0) is None -@setup_cache -@nt.raises(IOError) def test_pop_ioerror_eacces(cache): assert cache.set(0, 0) @@ -474,10 +451,10 @@ def test_pop_ioerror_eacces(cache): fetch.side_effect = io_error with mock.patch.object(cache, '_disk', disk): - cache.pop(0) + with pytest.raises(IOError): + cache.pop(0) -@setup_cache def test_delete(cache): cache[0] = 0 assert cache.delete(0) @@ -486,21 +463,18 @@ def test_delete(cache): assert len(cache.check()) == 0 -@nt.raises(KeyError) -@setup_cache def test_del(cache): - del cache[0] + with pytest.raises(KeyError): + del cache[0] -@nt.raises(KeyError) -@setup_cache def test_del_expired(cache): cache.set(0, 0, expire=0.001) time.sleep(0.01) - del cache[0] + with pytest.raises(KeyError): + del cache[0] -@setup_cache def test_stats(cache): cache[0] = 0 @@ -525,7 +499,6 @@ def test_stats(cache): assert len(cache.check()) == 0 -@setup_cache def test_path(cache): cache[0] = u'abc' large_value = b'abc' * 2 ** 20 @@ -545,7 +518,6 @@ def test_path(cache): assert len(cache.check()) == 0 -@setup_cache def test_expire_rows(cache): cache.reset('cull_limit', 0) @@ -566,7 +538,6 @@ def test_expire_rows(cache): assert len(cache.check()) == 0 -@setup_cache def test_least_recently_stored(cache): cache.reset('eviction_policy', u'least-recently-stored') cache.reset('size_limit', int(10.1e6)) @@ -602,7 +573,6 @@ def test_least_recently_stored(cache): assert len(cache.check()) == 0 -@setup_cache def test_least_recently_used(cache): cache.reset('eviction_policy', u'least-recently-used') cache.reset('size_limit', int(10.1e6)) @@ -633,7 +603,6 @@ def test_least_recently_used(cache): assert len(cache.check()) == 0 -@setup_cache def test_least_frequently_used(cache): cache.reset('eviction_policy', u'least-frequently-used') cache.reset('size_limit', int(10.1e6)) @@ -662,16 +631,14 @@ def test_least_frequently_used(cache): assert len(cache.check()) == 0 -@nt.raises(OSError) -@setup_cache def test_filename_error(cache): func = mock.Mock(side_effect=OSError(errno.EACCES)) with mock.patch('os.makedirs', func): - cache._disk.filename() + with pytest.raises(OSError): + cache._disk.filename() -@setup_cache def test_remove_error(cache): func = mock.Mock(side_effect=OSError(errno.EACCES)) @@ -687,7 +654,6 @@ def test_remove_error(cache): raise Exception('test_remove_error failed') -@setup_cache def test_check(cache): blob = b'a' * 2 ** 20 keys = (0, 1, 1234, 56.78, u'hello', b'world', None) @@ -717,7 +683,6 @@ def test_check(cache): assert len(cache.check()) == 0 # Should display no warnings. -@setup_cache def test_integrity_check(cache): for value in range(1000): cache[value] = value @@ -738,7 +703,6 @@ def test_integrity_check(cache): assert len(cache.check()) == 0 -@setup_cache def test_expire(cache): cache.reset('cull_limit', 0) # Disable expiring keys on `set`. now = time.time() @@ -765,7 +729,6 @@ def test_tag_index(): shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_evict(cache): colors = ('red', 'blue', 'yellow') @@ -778,7 +741,6 @@ def test_evict(cache): assert len(cache.check()) == 0 -@setup_cache def test_clear(cache): for value in range(100): cache[value] = value @@ -788,16 +750,14 @@ def test_clear(cache): assert len(cache.check()) == 0 -@setup_cache -@nt.raises(dc.Timeout) def test_clear_timeout(cache): transact = mock.Mock() transact.side_effect = dc.Timeout with mock.patch.object(cache, '_transact', transact): - cache.clear() + with pytest.raises(dc.Timeout): + cache.clear() -@setup_cache def test_tag(cache): assert cache.set(0, None, tag=u'zero') assert cache.set(1, None, tag=1234) @@ -810,7 +770,6 @@ def test_tag(cache): assert cache.get(3, tag=True) == (None, b'three') -@setup_cache def test_with(cache): with dc.Cache('tmp') as tmp: tmp[u'a'] = 0 @@ -820,14 +779,12 @@ def test_with(cache): assert cache[u'b'] == 1 -@setup_cache def test_contains(cache): assert 0 not in cache cache[0] = 0 assert 0 in cache -@setup_cache def test_add(cache): assert cache.add(1, 1) assert cache.get(1) == 1 @@ -840,7 +797,6 @@ def test_add(cache): cache.check() -@setup_cache def test_add_large_value(cache): value = b'abcd' * 2 ** 20 assert cache.add(b'test-key', value) @@ -850,8 +806,6 @@ def test_add_large_value(cache): cache.check() -@setup_cache -@nt.raises(dc.Timeout) def test_add_timeout(cache): local = mock.Mock() con = mock.Mock() @@ -862,14 +816,14 @@ def test_add_timeout(cache): con.execute = execute execute.side_effect = sqlite3.OperationalError - try: - with mock.patch.object(cache, '_local', local): - cache.add(0, 0) - finally: - cache.check() + with pytest.raises(dc.Timeout): + try: + with mock.patch.object(cache, '_local', local): + cache.add(0, 0) + finally: + cache.check() -@setup_cache def test_incr(cache): assert cache.incr('key', default=5) == 6 assert cache.incr('key', 2) == 8 @@ -881,22 +835,19 @@ def test_incr(cache): assert cache.incr('key') == 1 -@setup_cache -@nt.raises(KeyError) def test_incr_insert_keyerror(cache): - cache.incr('key', default=None) + with pytest.raises(KeyError): + cache.incr('key', default=None) -@setup_cache -@nt.raises(KeyError) def test_incr_update_keyerror(cache): assert cache.set('key', 100, expire=0.100) assert cache.get('key') == 100 time.sleep(0.120) - cache.incr('key', default=None) + with pytest.raises(KeyError): + cache.incr('key', default=None) -@setup_cache def test_decr(cache): assert cache.decr('key', default=5) == 4 assert cache.decr('key', 2) == 2 @@ -908,8 +859,6 @@ def test_decr(cache): assert cache.decr('key') == -1 -@setup_cache -@nt.raises(StopIteration) def test_iter(cache): sequence = list('abcdef') + [('g',)] @@ -922,10 +871,10 @@ def test_iter(cache): cache['h'] = 7 - next(iterator) + with pytest.raises(StopIteration): + next(iterator) -@setup_cache def test_iter_expire(cache): cache.reset('cull_limit', 0) for num in range(100): @@ -934,13 +883,11 @@ def test_iter_expire(cache): assert list(cache) == list(range(100)) -@setup_cache -@nt.raises(StopIteration) def test_iter_error(cache): - next(iter(cache)) + with pytest.raises(StopIteration): + next(iter(cache)) -@setup_cache def test_reversed(cache): sequence = 'abcdef' @@ -960,13 +907,11 @@ def test_reversed(cache): assert False, 'StopIteration expected' -@setup_cache -@nt.raises(StopIteration) def test_reversed_error(cache): - next(reversed(cache)) + with pytest.raises(StopIteration): + next(reversed(cache)) -@setup_cache def test_push_pull(cache): for value in range(10): cache.push(value) @@ -978,7 +923,6 @@ def test_push_pull(cache): assert len(cache) == 0 -@setup_cache def test_push_pull_prefix(cache): for value in range(10): cache.push(value, prefix='key') @@ -992,7 +936,6 @@ def test_push_pull_prefix(cache): assert len(cache.check()) == 0 -@setup_cache def test_push_pull_extras(cache): cache.push('test') assert cache.pull() == (500000000000000, 'test') @@ -1025,7 +968,6 @@ def test_push_pull_extras(cache): assert len(cache.check()) == 0 -@setup_cache def test_push_pull_expire(cache): cache.push(0, expire=0.1) cache.push(0, expire=0.1) @@ -1037,7 +979,6 @@ def test_push_pull_expire(cache): assert len(cache.check()) == 0 -@setup_cache def test_push_pull_large_value(cache): value = b'test' * (2 ** 20) cache.push(value) @@ -1046,7 +987,6 @@ def test_push_pull_large_value(cache): assert len(cache.check()) == 0 -@setup_cache def test_pull_ioerror(cache): assert cache.push(0) == 500000000000000 @@ -1065,8 +1005,6 @@ def test_pull_ioerror(cache): assert cache.pull() == (None, None) -@setup_cache -@nt.raises(IOError) def test_pull_ioerror_eacces(cache): assert cache.push(0) == 500000000000000 @@ -1082,15 +1020,14 @@ def test_pull_ioerror_eacces(cache): fetch.side_effect = io_error with mock.patch.object(cache, '_disk', disk): - cache.pull() + with pytest.raises(IOError): + cache.pull() -@setup_cache def test_iterkeys(cache): assert list(cache.iterkeys()) == [] -@setup_cache def test_pickle(cache): for num, val in enumerate('abcde'): cache[val] = num @@ -1102,7 +1039,6 @@ def test_pickle(cache): assert other[key] == cache[key] -@setup_cache def test_pragmas(cache): results = [] @@ -1139,7 +1075,6 @@ def compare_pragmas(): assert all(results) -@setup_cache def test_size_limit_with_files(cache): cache.reset('cull_limit', 0) size_limit = 30 * cache.disk_min_file_size @@ -1154,7 +1089,6 @@ def test_size_limit_with_files(cache): assert cache.volume() <= size_limit -@setup_cache def test_size_limit_with_database(cache): cache.reset('cull_limit', 0) size_limit = 2 * cache.disk_min_file_size @@ -1170,7 +1104,6 @@ def test_size_limit_with_database(cache): assert cache.volume() <= size_limit -@setup_cache def test_cull_eviction_policy_none(cache): cache.reset('eviction_policy', 'none') size_limit = 2 * cache.disk_min_file_size @@ -1186,7 +1119,6 @@ def test_cull_eviction_policy_none(cache): assert cache.volume() > size_limit -@setup_cache def test_cull_size_limit_0(cache): cache.reset('cull_limit', 0) size_limit = 2 * cache.disk_min_file_size @@ -1202,8 +1134,6 @@ def test_cull_size_limit_0(cache): assert cache.volume() <= size_limit -@setup_cache -@nt.raises(dc.Timeout) def test_cull_timeout(cache): transact = mock.Mock() transact.side_effect = [dc.Timeout] @@ -1211,10 +1141,10 @@ def test_cull_timeout(cache): with mock.patch.object(cache, 'expire', lambda now: 0): with mock.patch.object(cache, 'volume', lambda: int(1e12)): with mock.patch.object(cache, '_transact', transact): - cache.cull() + with pytest.raises(dc.Timeout): + cache.cull() -@setup_cache def test_key_roundtrip(cache): key_part_0 = u"part0" key_part_1 = u"part1" @@ -1334,7 +1264,6 @@ def test_rsync(): shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_custom_eviction_policy(cache): dc.EVICTION_POLICY['lru-gt-1s'] = { 'init': ( @@ -1371,7 +1300,6 @@ def test_custom_eviction_policy(cache): assert cache.volume() < size_limit -@setup_cache def test_lru_incr(cache): cache.reset('eviction_policy', 'least-recently-used') cache.incr(0) From 2aa9ee2722d3632f05847672f8fbf4507edb36a2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 17:02:15 -0700 Subject: [PATCH 042/329] Update Deque tests for pytest --- tests/test_deque.py | 82 +++++++++++++-------------------------------- 1 file changed, 23 insertions(+), 59 deletions(-) diff --git a/tests/test_deque.py b/tests/test_deque.py index 14329d1..f9ba35b 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -2,8 +2,8 @@ import functools as ft import mock -import nose.tools as nt import pickle +import pytest import shutil import diskcache as dc @@ -17,17 +17,14 @@ def rmdir(directory): pass -def setup_deque(func): - @ft.wraps(func) - def wrapper(): - deque = dc.Deque() - try: - func(deque) - except Exception: - rmdir(deque.directory) - raise - - return wrapper +@pytest.fixture +def deque(): + deque = dc.Deque() + try: + yield deque + except Exception: + rmdir(deque.directory) + raise def test_init(): @@ -55,7 +52,6 @@ def test_init(): rmdir(directory) -@setup_deque def test_getsetdel(deque): sequence = list('abcde') assert len(deque) == 0 @@ -83,14 +79,12 @@ def test_getsetdel(deque): assert len(deque) == 0 -@setup_deque def test_reversed(deque): sequence = list('abcde') deque += sequence assert list(reversed(deque)) == list(reversed(sequence)) -@setup_deque def test_state(deque): sequence = list('abcde') deque.extend(sequence) @@ -100,7 +94,6 @@ def test_state(deque): assert values == sequence -@setup_deque def test_compare(deque): assert not (deque == {}) assert not (deque == [0]) @@ -110,30 +103,26 @@ def test_compare(deque): assert deque <= [1] -@nt.raises(IndexError) -@setup_deque def test_indexerror_negative(deque): - deque[-1] + with pytest.raises(IndexError): + deque[-1] -@nt.raises(IndexError) -@setup_deque def test_indexerror(deque): - deque[0] + with pytest.raises(IndexError): + deque[0] -@nt.raises(IndexError) -@setup_deque def test_indexerror_islice(deque): islice = mock.Mock(side_effect=StopIteration) deque.append(0) with mock.patch('diskcache.persistent.islice', islice): - deque[0] + with pytest.raises(IndexError): + deque[0] -@setup_deque def test_get_timeout(deque): cache = mock.MagicMock() cache.__len__.return_value = 1 @@ -146,7 +135,6 @@ def test_get_timeout(deque): deque[0] -@setup_deque def test_set_timeout(deque): cache = mock.MagicMock() cache.__len__.return_value = 1 @@ -159,7 +147,6 @@ def test_set_timeout(deque): deque[0] = 0 -@setup_deque def test_del_timeout(deque): cache = mock.MagicMock() cache.__len__.return_value = 1 @@ -178,7 +165,6 @@ def test_repr(): assert repr(deque) == 'Deque(directory=%r)' % directory -@setup_deque def test_iter_timeout(deque): cache = mock.MagicMock() cache.iterkeys.side_effect = [iter([0, 1])] @@ -188,7 +174,6 @@ def test_iter_timeout(deque): assert list(deque) == [0] -@setup_deque def test_reversed_timeout(deque): cache = mock.MagicMock() cache.iterkeys.side_effect = [iter([0, 1])] @@ -198,7 +183,6 @@ def test_reversed_timeout(deque): assert list(reversed(deque)) == [0] -@setup_deque def test_append_timeout(deque): cache = mock.MagicMock() cache.push.side_effect = [dc.Timeout, None] @@ -207,7 +191,6 @@ def test_append_timeout(deque): deque.append(0) -@setup_deque def test_appendleft_timeout(deque): cache = mock.MagicMock() cache.push.side_effect = [dc.Timeout, None] @@ -216,7 +199,6 @@ def test_appendleft_timeout(deque): deque.appendleft(0) -@setup_deque def test_count(deque): deque += 'abbcccddddeeeee' @@ -224,21 +206,18 @@ def test_count(deque): assert deque.count(value) == index -@setup_deque def test_extend(deque): sequence = list('abcde') deque.extend(sequence) assert deque == sequence -@setup_deque def test_extendleft(deque): sequence = list('abcde') deque.extendleft(sequence) assert deque == list(reversed(sequence)) -@setup_deque def test_pop(deque): sequence = list('abcde') deque.extend(sequence) @@ -247,13 +226,11 @@ def test_pop(deque): assert deque.pop() == sequence.pop() -@nt.raises(IndexError) -@setup_deque def test_pop_indexerror(deque): - deque.pop() + with pytest.raises(IndexError): + deque.pop() -@setup_deque def test_pop_timeout(deque): cache = mock.MagicMock() cache.pull.side_effect = [dc.Timeout, (None, 0)] @@ -262,7 +239,6 @@ def test_pop_timeout(deque): assert deque.pop() == 0 -@setup_deque def test_popleft(deque): sequence = list('abcde') deque.extend(sequence) @@ -273,13 +249,11 @@ def test_popleft(deque): del sequence[0] -@nt.raises(IndexError) -@setup_deque def test_popleft_indexerror(deque): - deque.popleft() + with pytest.raises(IndexError): + deque.popleft() -@setup_deque def test_popleft_timeout(deque): cache = mock.MagicMock() cache.pull.side_effect = [dc.Timeout, (None, 0)] @@ -288,7 +262,6 @@ def test_popleft_timeout(deque): assert deque.popleft() == 0 -@setup_deque def test_remove(deque): deque.extend('abaca') deque.remove('a') @@ -299,7 +272,6 @@ def test_remove(deque): assert deque == 'bc' -@setup_deque def test_remove_timeout(deque): cache = mock.MagicMock() cache.iterkeys.side_effect = [iter([0, 1, 2, 3, 4])] @@ -310,26 +282,22 @@ def test_remove_timeout(deque): deque.remove(3) -@nt.raises(ValueError) -@setup_deque def test_remove_valueerror(deque): - deque.remove(0) + with pytest.raises(ValueError): + deque.remove(0) -@setup_deque def test_reverse(deque): deque += 'abcde' deque.reverse() assert deque == 'edcba' -@nt.raises(TypeError) -@setup_deque def test_rotate_typeerror(deque): - deque.rotate(0.5) + with pytest.raises(TypeError): + deque.rotate(0.5) -@setup_deque def test_rotate(deque): deque.rotate(1) deque.rotate(-1) @@ -338,14 +306,12 @@ def test_rotate(deque): assert deque == 'cdeab' -@setup_deque def test_rotate_negative(deque): deque += 'abcde' deque.rotate(-2) assert deque == 'cdeab' -@setup_deque def test_rotate_indexerror(deque): deque += 'abc' @@ -357,7 +323,6 @@ def test_rotate_indexerror(deque): deque.rotate(1) -@setup_deque def test_rotate_indexerror_negative(deque): deque += 'abc' @@ -369,7 +334,6 @@ def test_rotate_indexerror_negative(deque): deque.rotate(-1) -@setup_deque def test_clear_timeout(deque): cache = mock.MagicMock() cache.clear.side_effect = [dc.Timeout, None] From a455fe83b172942e0de863c67e50eb806f95ae66 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 17:02:33 -0700 Subject: [PATCH 043/329] Add pytest-env and env vars for pytest --- requirements.txt | 1 + tox.ini | 3 +++ 2 files changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index c1cc167..ef3d63a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ pylint pytest pytest-cov pytest-django +pytest-env sphinx tox twine diff --git a/tox.ini b/tox.ini index 7864ed4..f5e0916 100644 --- a/tox.ini +++ b/tox.ini @@ -23,6 +23,9 @@ addopts= --ignore tests/plot.py norecursedirs=site-packages testpaths=docs diskcache tests +env = + DJANGO_SETTINGS_MODULE=tests.settings + PYTHONPATH={PWD}:{PWD}/tests [testenv:lint] deps=pylint From faf962ae990b8c21ce7f7e09d9d2197d9d49a024 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 17:06:18 -0700 Subject: [PATCH 044/329] Update Index tests for pytest --- tests/test_index.py | 49 ++++++++++++--------------------------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/tests/test_index.py b/tests/test_index.py index 423c3c9..e5cd71d 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -2,8 +2,8 @@ import functools as ft import mock -import nose.tools as nt import pickle +import pytest import shutil import sys @@ -17,17 +17,14 @@ def rmdir(directory): pass -def setup_index(func): - @ft.wraps(func) - def wrapper(): - index = dc.Index() - try: - func(index) - except Exception: - rmdir(index.directory) - raise - - return wrapper +@pytest.fixture +def index(): + index = dc.Index() + try: + yield index + except Exception: + rmdir(index.directory) + raise def test_init(): @@ -64,7 +61,6 @@ def test_init(): assert index == mapping -@setup_index def test_getsetdel(index): letters = 'abcde' assert len(index) == 0 @@ -81,7 +77,6 @@ def test_getsetdel(index): assert len(index) == 0 -@setup_index def test_get_timeout(index): cache = mock.MagicMock() cache.__getitem__.side_effect = [dc.Timeout, 0] @@ -90,7 +85,6 @@ def test_get_timeout(index): assert index[0] == 0 -@setup_index def test_set_timeout(index): cache = mock.MagicMock() cache.__setitem__.side_effect = [dc.Timeout, None] @@ -99,7 +93,6 @@ def test_set_timeout(index): index[0] = 0 -@setup_index def test_del_timeout(index): cache = mock.MagicMock() cache.__delitem__.side_effect = [dc.Timeout, None] @@ -108,7 +101,6 @@ def test_del_timeout(index): del index[0] -@setup_index def test_pop(index): letters = 'abcde' assert len(index) == 0 @@ -124,13 +116,11 @@ def test_pop(index): assert len(index) == 0 -@nt.raises(KeyError) -@setup_index def test_pop_keyerror(index): - index.pop('a') + with pytest.raises(KeyError): + index.pop('a') -@setup_index def test_pop_timeout(index): cache = mock.MagicMock() cache.pop.side_effect = [dc.Timeout, 1] @@ -139,7 +129,6 @@ def test_pop_timeout(index): assert index.pop(0) == 1 -@setup_index def test_popitem(index): letters = 'abcde' @@ -152,13 +141,11 @@ def test_popitem(index): assert len(index) == 2 -@nt.raises(KeyError) -@setup_index def test_popitem_keyerror(index): - index.popitem() + with pytest.raises(KeyError): + index.popitem() -@setup_index def test_popitem_timeout(index): cache = mock.MagicMock() cache.__reversed__ = mock.Mock() @@ -170,13 +157,11 @@ def test_popitem_timeout(index): assert value == (0, 1) -@setup_index def test_setdefault(index): assert index.setdefault('a', 0) == 0 assert index.setdefault('a', 1) == 0 -@setup_index def test_setdefault_timeout(index): cache = mock.MagicMock() cache.__getitem__ = mock.Mock() @@ -189,7 +174,6 @@ def test_setdefault_timeout(index): assert value == 0 -@setup_index def test_iter(index): letters = 'abcde' @@ -200,7 +184,6 @@ def test_iter(index): assert index[key] == num -@setup_index def test_reversed(index): letters = 'abcde' @@ -211,7 +194,6 @@ def test_reversed(index): assert index[key] == (len(letters) - num - 1) -@setup_index def test_state(index): mapping = {'a': 5, 'b': 4, 'c': 3, 'd': 2, 'e': 1} index.update(mapping) @@ -221,7 +203,6 @@ def test_state(index): assert values == mapping -@setup_index def test_push_timeout(index): cache = mock.MagicMock() cache.push.side_effect = [dc.Timeout, None] @@ -230,7 +211,6 @@ def test_push_timeout(index): index.push(0) -@setup_index def test_pull_timeout(index): cache = mock.MagicMock() cache.pull.side_effect = [dc.Timeout, None] @@ -239,7 +219,6 @@ def test_pull_timeout(index): index.pull(0) -@setup_index def test_clear_timeout(index): cache = mock.MagicMock() cache.clear.side_effect = [dc.Timeout, None] @@ -249,7 +228,6 @@ def test_clear_timeout(index): if sys.hexversion < 0x03000000: - @setup_index def test_itervalues_timeout(index): cache = mock.MagicMock() cache.__iter__.side_effect = [iter([0, 1, 2])] @@ -259,7 +237,6 @@ def test_itervalues_timeout(index): assert list(index.itervalues()) == [1, 2] - @setup_index def test_iteritems_timeout(index): cache = mock.MagicMock() cache.__iter__.side_effect = [iter([0, 1, 2])] From fdee183bb71c56ec002b38351cfa4bc93537b20e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 17:24:25 -0700 Subject: [PATCH 045/329] Update FanoutCache tests for pytest --- tests/test_fanout.py | 132 +++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 86 deletions(-) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index e302bed..6d5b4f1 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -8,9 +8,9 @@ import hashlib import io import mock -import nose.tools as nt import os import os.path as op +import pytest import random import shutil import sqlite3 @@ -33,21 +33,15 @@ if sys.hexversion < 0x03000000: range = xrange -def setup_cache(func=None, **options): - if func is None: - return lambda func: setup_cache(func, **options) - @ft.wraps(func) - def wrapper(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.FanoutCache('tmp', **options) as cache: - func(cache) - shutil.rmtree('tmp', ignore_errors=True) - - return wrapper +@pytest.fixture +def cache(): + shutil.rmtree('tmp', ignore_errors=True) + with dc.FanoutCache('tmp') as cache: + yield cache + shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_init(cache): assert cache.directory == 'tmp' @@ -65,7 +59,6 @@ def test_init(cache): cache.check() -@setup_cache def test_set_get_delete(cache): for value in range(100): cache.set(value, value) @@ -104,7 +97,6 @@ def test_set_get_delete(cache): cache.check() -@setup_cache def test_set_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -118,7 +110,6 @@ def test_set_timeout(cache): assert not cache.set(0, 0) -@setup_cache def test_set_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -133,14 +124,12 @@ def test_set_timeout_retry(cache): cache[1] = 1 -@setup_cache def test_add(cache): assert cache.add(0, 0) assert not cache.add(0, 1) assert cache.get(0) == 0 -@setup_cache def test_add_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -154,7 +143,6 @@ def test_add_timeout(cache): assert not cache.add(0, 0) -@setup_cache def test_add_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -178,32 +166,32 @@ def stress_add(cache, limit, results): results.append(total) -@setup_cache(shards=1) -def test_add_concurrent(cache): - results = co.deque() - limit = 1000 +def test_add_concurrent(): + shutil.rmtree('tmp', ignore_errors=True) + with dc.FanoutCache('tmp', shards=1) as cache: + results = co.deque() + limit = 1000 - threads = [ - threading.Thread(target=stress_add, args=(cache, limit, results)) - for _ in range(16) - ] + threads = [ + threading.Thread(target=stress_add, args=(cache, limit, results)) + for _ in range(16) + ] - for thread in threads: - thread.start() + for thread in threads: + thread.start() - for thread in threads: - thread.join() + for thread in threads: + thread.join() - assert sum(results) == limit - cache.check() + assert sum(results) == limit + cache.check() + shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_incr(cache): cache.incr('key', delta=3) == 3 -@setup_cache def test_incr_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -217,7 +205,6 @@ def test_incr_timeout(cache): assert cache.incr('key', 1) is None -@setup_cache def test_incr_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -231,7 +218,6 @@ def test_incr_timeout_retry(cache): assert cache.incr('key', retry=True) == 1 -@setup_cache def test_decr(cache): cache.decr('key', delta=2) == -2 @@ -242,27 +228,28 @@ def stress_incr(cache, limit): time.sleep(0.001) -@setup_cache(shards=1, timeout=0.001) -def test_incr_concurrent(cache): - count = 16 - limit = 50 +def test_incr_concurrent(): + shutil.rmtree('tmp', ignore_errors=True) + with dc.FanoutCache('tmp', shards=1, timeout=0.001) as cache: + count = 16 + limit = 50 - threads = [ - threading.Thread(target=stress_incr, args=(cache, limit)) - for _ in range(count) - ] + threads = [ + threading.Thread(target=stress_incr, args=(cache, limit)) + for _ in range(count) + ] - for thread in threads: - thread.start() + for thread in threads: + thread.start() - for thread in threads: - thread.join() + for thread in threads: + thread.join() - assert cache.get(b'key') == count * limit - cache.check() + assert cache.get(b'key') == count * limit + cache.check() + shutil.rmtree('tmp', ignore_errors=True) -@setup_cache def test_get_timeout(cache): cache.set(0, 0) @@ -278,7 +265,6 @@ def test_get_timeout(cache): assert cache.get(0) is None -@setup_cache def test_get_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -292,7 +278,6 @@ def test_get_timeout_retry(cache): assert cache.get(0, retry=True) == 0 -@setup_cache def test_pop(cache): for num in range(100): cache[num] = num @@ -301,7 +286,6 @@ def test_pop(cache): assert cache.pop(num) == num -@setup_cache def test_pop_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -315,7 +299,6 @@ def test_pop_timeout(cache): assert cache.pop(0) is None -@setup_cache def test_pop_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -329,7 +312,6 @@ def test_pop_timeout_retry(cache): assert cache.pop(0, retry=True) == 0 -@setup_cache def test_delete_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -343,7 +325,6 @@ def test_delete_timeout(cache): assert not cache.delete(0) -@setup_cache def test_delete_timeout_retry(cache): shards = mock.Mock() shard = mock.Mock() @@ -357,20 +338,17 @@ def test_delete_timeout_retry(cache): assert cache.delete(0, retry=True) -@setup_cache def test_delitem(cache): cache[0] = 0 assert cache[0] == 0 del cache[0] -@setup_cache -@nt.raises(KeyError) def test_delitem_keyerror(cache): - del cache[0] + with pytest.raises(KeyError): + del cache[0] -@setup_cache def test_delitem_timeout(cache): shards = mock.Mock() shard = mock.Mock() @@ -384,7 +362,6 @@ def test_delitem_timeout(cache): del cache[0] -@setup_cache def test_tag_index(cache): assert cache.tag_index == 0 cache.create_tag_index() @@ -393,27 +370,23 @@ def test_tag_index(cache): assert cache.tag_index == 0 -@setup_cache def test_read(cache): cache.set(0, b'abcd' * 2 ** 20) with cache.read(0) as reader: assert reader is not None -@nt.raises(KeyError) -@setup_cache def test_read_keyerror(cache): - with cache.read(0) as reader: - pass + with pytest.raises(KeyError): + with cache.read(0) as reader: + pass -@nt.raises(KeyError) -@setup_cache def test_getitem_keyerror(cache): - cache[0] + with pytest.raises(KeyError): + cache[0] -@setup_cache def test_expire(cache): cache.reset('cull_limit', 0) @@ -428,7 +401,6 @@ def test_expire(cache): assert cache.expire() == 100 -@setup_cache def test_evict(cache): colors = ('red', 'blue', 'yellow') @@ -441,7 +413,6 @@ def test_evict(cache): assert len(cache.check()) == 0 -@setup_cache def test_size_limit_with_files(cache): shards = 8 cache.reset('cull_limit', 0) @@ -457,7 +428,6 @@ def test_size_limit_with_files(cache): assert (cache.volume() // shards) <= size_limit -@setup_cache def test_size_limit_with_database(cache): shards = 8 cache.reset('cull_limit', 0) @@ -474,7 +444,6 @@ def test_size_limit_with_database(cache): assert (cache.volume() // shards) <= size_limit -@setup_cache def test_clear(cache): for value in range(100): cache[value] = value @@ -484,7 +453,6 @@ def test_clear(cache): assert len(cache.check()) == 0 -@setup_cache def test_remove_timeout(cache): shard = mock.Mock() clear = mock.Mock() @@ -496,7 +464,6 @@ def test_remove_timeout(cache): assert cache.clear() == 5 -@setup_cache def test_reset_timeout(cache): shard = mock.Mock() reset = mock.Mock() @@ -508,7 +475,6 @@ def test_reset_timeout(cache): assert cache.reset('blah', 1) == 0 -@setup_cache def test_stats(cache): for value in range(100): cache[value] = value @@ -534,20 +500,17 @@ def test_stats(cache): assert len(cache.check()) == 0 -@setup_cache def test_volume(cache): volume = sum(shard.volume() for shard in cache._shards) assert volume == cache.volume() -@setup_cache def test_iter(cache): for num in range(100): cache[num] = num assert set(cache) == set(range(100)) -@setup_cache def test_iter_expire(cache): """Test iteration with expiration. @@ -563,7 +526,6 @@ def test_iter_expire(cache): assert set(cache) == set() -@setup_cache def test_reversed(cache): for num in range(100): cache[num] = num @@ -571,7 +533,6 @@ def test_reversed(cache): assert list(cache) == list(reversed(reverse)) -@setup_cache def test_pickle(cache): for num, val in enumerate('abcde'): cache[val] = num @@ -583,7 +544,6 @@ def test_pickle(cache): assert other[key] == cache[key] -@setup_cache def test_memoize(cache): count = 1000 From 24de717f406966e2d57a399df0e4059a53a9a82b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 20:16:02 -0700 Subject: [PATCH 046/329] Exclude default cache dir from testing --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7af3b5a..0ef1c27 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /env*/ # test files/directories +/.cache/ .coverage .pytest_cache/ /.tox/ From dd58adc7b0ff5168b5392c3d9d64b77073235f4f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 20:16:21 -0700 Subject: [PATCH 047/329] Add pylibmc and django_redis for Django tests --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index ef3d63a..c982310 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ coverage django==1.11.* +django_redis doc8 gj mock nose +pylibmc pylint pytest pytest-cov From 787f856c535a3dff2597b49bcd81a7ad080110bb Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 21:58:51 -0700 Subject: [PATCH 048/329] Update pylint settings --- .pylintrc | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.pylintrc b/.pylintrc index 39d96b0..1750d67 100644 --- a/.pylintrc +++ b/.pylintrc @@ -138,7 +138,12 @@ disable=print-statement, xreadlines-attribute, deprecated-sys-function, exception-escape, - comprehension-escape + comprehension-escape, + no-else-return, + no-member, + useless-object-inheritance, + inconsistent-return-statements, + ungrouped-imports # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -175,7 +180,7 @@ score=yes [REFACTORING] # Maximum number of nested blocks for function / method body -max-nested-blocks=5 +max-nested-blocks=6 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then @@ -324,7 +329,7 @@ indent-string=' ' max-line-length=100 # Maximum number of lines in a module. -max-module-lines=1000 +max-module-lines=2000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. @@ -532,7 +537,7 @@ valid-metaclass-classmethod-first-arg=cls [DESIGN] # Maximum number of arguments for function / method. -max-args=5 +max-args=8 # Maximum number of attributes for a class (see R0902). max-attributes=7 @@ -541,22 +546,22 @@ max-attributes=7 max-bool-expr=5 # Maximum number of branch for function / method body. -max-branches=12 +max-branches=20 # Maximum number of locals for function / method body. -max-locals=15 +max-locals=30 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). -max-public-methods=20 +max-public-methods=25 # Maximum number of return / yield for function / method body. -max-returns=6 +max-returns=8 # Maximum number of statements in function / method body. -max-statements=50 +max-statements=60 # Minimum number of public methods for a class (see R0903). min-public-methods=2 From c91272d6b78cf3f7178e24d418d8d82e22c3192f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 22:02:58 -0700 Subject: [PATCH 049/329] Update tutorial and add doctests --- docs/tutorial.rst | 112 ++++++++++++++++++++++++------------------ tests/test_doctest.py | 10 ++++ 2 files changed, 75 insertions(+), 47 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index faf7e28..52a95a6 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -93,16 +93,17 @@ Cache objects is relatively slow, and since all operations are atomic, you can safely leave Cache objects open. >>> cache.set(b'key', b'value') + True >>> cache.close() >>> cache.get(b'key') # Automatically opens, but slower. - 'value' + b'value' Set an item, get a value, and delete a key using the usual operators: >>> cache = Cache('/tmp/mycachedir') >>> cache[b'key'] = b'value' >>> cache[b'key'] - 'value' + b'value' >>> b'key' in cache True >>> del cache[b'key'] @@ -111,7 +112,7 @@ There's also a :meth:`set ` method with additional keyword parameters: `expire`, `read`, and `tag`. >>> from io import BytesIO - >>> cache.set(b'key', BytesIO('value'), expire=5, read=True, tag=u'data') + >>> cache.set(b'key', BytesIO(b'value'), expire=5, read=True, tag='data') True In the example above: the key expires in 5 seconds, the value is read as a @@ -119,11 +120,14 @@ file-like object, and tag metadata is stored with the key. Another method, :meth:`get ` supports querying extra information with `default`, `read`, `expire_time`, and `tag` keyword parameters. - >>> cache.get(b'key', default=b'', read=True, expire_time=True, tag=True) - (<_io.BufferedReader - name=u'/tmp/mycachedir/1d/6e/128a921c3b8a9027c1f69989f3ac.val'>, - 1457066214.784396, - u'data') + >>> result = cache.get(b'key', default=b'', read=True, expire_time=True, tag=True) + >>> reader, timestamp, tag = result + >>> type(reader) + + >>> type(timestamp) + + >>> tag + 'data' The return value is a tuple containing the value, expire time (seconds from epoch), and tag. Because we passed ``read=True`` the value is returned as a @@ -154,14 +158,14 @@ Increment and decrement methods also support a keyword parameter, `default`, which will be used for missing keys. When ``None``, incrementing or decrementing a missing key will raise a :exc:`KeyError`. - >>> cache.incr(u'alice') + >>> cache.incr('alice') 1 - >>> cache.decr(u'bob', default=-9) + >>> cache.decr('bob', default=-9) -10 - >>> cache.incr(u'carol', default=None) + >>> cache.incr('carol', default=None) Traceback (most recent call last): ... - KeyError: u'carol' + KeyError: 'carol' Increment and decrement operations are atomic and assume the value may be stored in a SQLite column. Most builds that target machines with 64-bit pointer @@ -171,13 +175,14 @@ Like :meth:`delete ` and :meth:`get `, the method :meth:`pop ` can be used to delete an item in the cache and return its value. - >>> cache.pop(u'alice') + >>> cache.pop('alice') 1 - >>> cache.pop(u'dave', default=u'does not exist') - u'does not exist' - >>> cache.set(u'dave', 0, expire=None, tag=u'admin') - >>> cache.pop(u'dave', expire_time=True, tag=True) - (0, None, u'admin') + >>> cache.pop('dave', default='does not exist') + 'does not exist' + >>> cache.set('dave', 0, expire=None, tag='admin') + True + >>> cache.pop('dave', expire_time=True, tag=True) + (0, None, 'admin') The :meth:`pop ` operation is atomic and using :meth:`incr ` together is an accurate method for counting and dumping @@ -186,9 +191,12 @@ the `read` argument is not supported. Another four methods remove items from the cache. + >>> cache.clear() + 3 >>> cache.reset('cull_limit', 0) # Disable automatic evictions. + 0 >>> for num in range(10): - ... cache.set(num, num, expire=0) # Expire immediately. + ... _ = cache.set(num, num, expire=0) # Expire immediately. >>> len(cache) 10 >>> list(cache) @@ -206,8 +214,9 @@ items you must explicitly call :meth:`expire ` which works regardless of the :ref:`cull_limit `. >>> for num in range(100): - ... cache.set(num, num, tag=u'odd' if num % 2 else u'even') - >>> cache.evict(u'even') + ... _ = cache.set(num, num, tag='odd' if num % 2 else 'even') + >>> cache.evict('even') + 50 .. _tutorial-tag-index: @@ -218,8 +227,9 @@ can be created. To do so, initialize the cache with ``tag_index=True``. >>> cache = Cache('/tmp/mycachedir', tag_index=True) >>> for num in range(100): - ... cache.set(num, num, tag=(num % 2)) + ... _ = cache.set(num, num, tag=(num % 2)) >>> cache.evict(0) + 50 Likewise, the tag index may be created or dropped using methods:: @@ -239,16 +249,19 @@ removing expired items from the cache and then uses the eviction policy to remove items until the cache volume is less than the size limit. >>> cache.clear() + 50 >>> cache.reset('size_limit', int(1e6)) + 1000000 >>> cache.reset('cull_limit', 0) + 0 >>> for count in range(1000): - >>> cache[count] = b'A' * 1000 - >>> cache.volume() - 1437696 - >>> cache.cull() - 320 - >>> cache.volume() - 999424 + ... cache[count] = b'A' * 1000 + >>> cache.volume() > int(1e6) + True + >>> cache.cull() > 0 + True + >>> cache.volume() < int(1e6) + True Some users may defer all culling to a cron-like process by setting the :ref:`cull_limit ` to zero and calling :meth:`cull @@ -259,7 +272,8 @@ Some users may defer all culling to a cron-like process by setting the :meth:`Clear ` simply removes all items from the cache. - >>> cache.clear() + >>> cache.clear() > 0 + True Each of these methods is designed to work concurrent to others. None of them block readers or writers in other threads or processes. @@ -268,8 +282,8 @@ Lastly, three methods support metadata about the cache. The first is :meth:`volume ` which returns the estimated total size in bytes of the cache directory on disk. - >>> cache.volume() - 9216 + >>> cache.volume() < int(1e5) + True .. _tutorial-statistics: @@ -279,11 +293,12 @@ and misses. Cache statistics must first be enabled. >>> cache.stats(enable=True) (0, 0) >>> for num in range(100): - ... cache.set(num, num) + ... _ = cache.set(num, num) >>> for num in range(150): - ... cache.get(num) - >>> cache.stats(enable=False, reset=True) - (100, 50) # 100 hits, 50 misses + ... _ = cache.get(num) + >>> hits, misses = cache.stats(enable=False, reset=True) + >>> (hits, misses) + (100, 50) Cache statistics are useful when evaluating different :ref:`eviction policies `. By default, statistics are disabled as they @@ -293,8 +308,9 @@ are not counted in cache statistics. The third is :meth:`check ` which verifies cache consistency. It can also fix inconsistencies and reclaim unused space. - >>> cache.check(fix=True) - [] + >>> warning, = cache.check(fix=True) + >>> type(warning.message) + The return value is a list of warnings. @@ -457,8 +473,8 @@ access and editing at both front and back sides. :class:`Deque >>> deque.appendleft('foo') >>> len(deque) 4 - >>> deque.directory - '/tmp/...' + >>> type(deque.directory) + >>> other = Deque(directory=deque.directory) >>> len(other) 4 @@ -535,6 +551,8 @@ are updated lazily. Prefer idioms like :meth:`len ` directly. >>> cache = Cache('/tmp/mycachedir', size_limit=int(4e9)) + >>> cache.clear() + 100 >>> cache.size_limit 4000000000 >>> cache.disk_min_file_size @@ -544,7 +562,7 @@ are updated lazily. Prefer idioms like :meth:`len >>> cache.set(b'key', 1.234) True >>> cache.count # Stale attribute. - 0 + 100 >>> cache.reset('count') # Prefer: len(cache) 1 @@ -602,12 +620,12 @@ policy. The policy can be set during initialization using a keyword argument. >>> cache = Cache('/tmp/mydir') >>> cache.eviction_policy - u'least-recently-stored' - >>> cache = Cache('/tmp/mydir', eviction_policy=u'least-frequently-used') + 'least-recently-stored' + >>> cache = Cache('/tmp/mydir', eviction_policy='least-frequently-used') >>> cache.eviction_policy - u'least-frequently-used' - >>> cache.reset('eviction_policy', u'least-recently-used') - u'least-recently-used' + 'least-frequently-used' + >>> cache.reset('eviction_policy', 'least-recently-used') + 'least-recently-used' Though the eviction policy is changed, the previously created indexes will not be dropped. Prefer to always specify the eviction policy as a keyword argument @@ -658,7 +676,7 @@ example below uses compressed JSON. data = json.loads(zlib.decompress(data).decode('utf-8')) return data - with Cache('/tmp/dir', disk=JSONDisk, disk_compress_level=6) as cache: + with Cache('/tmp/mydir', disk=JSONDisk, disk_compress_level=6) as cache: pass Four data types can be stored natively in the cache metadata database: diff --git a/tests/test_doctest.py b/tests/test_doctest.py index ba8eb6b..b523e46 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -1,5 +1,6 @@ import doctest import shutil +import sys import diskcache.core import diskcache.djangocache @@ -43,3 +44,12 @@ def test_persistent(): rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.persistent) assert failures == 0 + + +def test_tutorial(): + if sys.hexversion < 0x03000000: + return + rmdir('/tmp/mycachedir') + rmdir('/tmp/mydir') + failures, _ = doctest.testfile('../docs/tutorial.rst') + assert failures == 0 From 8e8f556c08e37781fd7bc880777894b7f71c0cc8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 22:04:34 -0700 Subject: [PATCH 050/329] Add django_redis and pylibmc for tests --- tox.ini | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index f5e0916..86126ce 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,9 @@ skip_missing_interpreters=True [testenv] deps= django==1.11.* + django_redis mock + pylibmc pytest pytest-django commands=python -m pytest @@ -15,11 +17,10 @@ setenv = [pytest] addopts= - --doctest-modules - --doctest-glob "*.rst" --ignore tests/benchmark_core.py --ignore tests/benchmark_djangocache.py --ignore tests/benchmark_glob.py + --ignore tests/issue_85.py --ignore tests/plot.py norecursedirs=site-packages testpaths=docs diskcache tests @@ -28,5 +29,7 @@ env = PYTHONPATH={PWD}:{PWD}/tests [testenv:lint] -deps=pylint +deps= + django==1.11.* + pylint commands=pylint diskcache From 6a797078d51e36a8da5b281ec3f39611fc4cde68 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 22:05:47 -0700 Subject: [PATCH 051/329] Remove unnecessary pass statements --- diskcache/core.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 0064c79..452d61c 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -40,7 +40,6 @@ except NameError: class WindowsError(Exception): "Windows error place-holder on platforms without support." - pass class Constant(tuple): "Pretty display of immutable constant." @@ -343,17 +342,14 @@ def remove(self, filename): class Timeout(Exception): "Database timeout expired." - pass class UnknownFileWarning(UserWarning): "Warning used by Cache.check for unknown files." - pass class EmptyDirWarning(UserWarning): "Warning used by Cache.check for empty directories." - pass class Cache(object): From 8d33162fda890384eb58a1725c075eb7efd790a3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 22:13:33 -0700 Subject: [PATCH 052/329] Change index to copy README and update license --- LICENSE | 17 +++--- README.rst | 27 +++++----- docs/index.rst | 137 +------------------------------------------------ 3 files changed, 23 insertions(+), 158 deletions(-) diff --git a/LICENSE b/LICENSE index ff2e17b..3259b98 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,12 @@ -Copyright 2016-2018 Grant Jenks +Copyright 2016-2019 Grant Jenks -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/README.rst b/README.rst index 341ccba..eae044c 100644 --- a/README.rst +++ b/README.rst @@ -62,7 +62,7 @@ Features - Thread-safe and process-safe - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction -- Developed on Python 2.7 +- Developed on Python 3.7 - Tested on CPython 2.7, 3.4, 3.5, 3.6, 3.7 and PyPy - Tested on Linux, Mac OS X, and Windows - Tested using Travis CI and AppVeyor CI @@ -111,8 +111,8 @@ introduction, benchmarks, development, and API. .. _`DiskCache API Reference`: http://www.grantjenks.com/docs/diskcache/api.html .. _`DiskCache Development`: http://www.grantjenks.com/docs/diskcache/development.html -Reference and Indices ---------------------- +Reference +--------- * `DiskCache Documentation`_ * `DiskCache at PyPI`_ @@ -124,21 +124,20 @@ Reference and Indices .. _`DiskCache at GitHub`: https://github.com/grantjenks/python-diskcache/ .. _`DiskCache Issue Tracker`: https://github.com/grantjenks/python-diskcache/issues/ -DiskCache License ------------------ +License +------- -Copyright 2016-2018 Grant Jenks +Copyright 2016-2019 Grant Jenks -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. .. _`DiskCache`: http://www.grantjenks.com/docs/diskcache/ diff --git a/docs/index.rst b/docs/index.rst index 68576df..9fe19ac 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,102 +1,7 @@ -DiskCache: Disk Backed Cache -============================ - -`DiskCache`_ is an Apache2 licensed disk and file backed cache library, written -in pure-Python, and compatible with Django. - -The cloud-based computing of 2018 puts a premium on memory. Gigabytes of empty -space is left on disks as processes vie for memory. Among these processes is -Memcached (and sometimes Redis) which is used as a cache. Wouldn't it be nice -to leverage empty disk space for caching? - -Django is Python's most popular web framework and ships with several caching -backends. Unfortunately the file-based cache in Django is essentially -broken. The culling method is random and large caches repeatedly scan a cache -directory which slows linearly with growth. Can you really allow it to take -sixty milliseconds to store a key in a cache with a thousand items? - -In Python, we can do better. And we can do it in pure-Python! - -:: - - In [1]: import pylibmc - In [2]: client = pylibmc.Client(['127.0.0.1'], binary=True) - In [3]: client[b'key'] = b'value' - In [4]: %timeit client[b'key'] - - 10000 loops, best of 3: 25.4 µs per loop - - In [5]: import diskcache as dc - In [6]: cache = dc.Cache('tmp') - In [7]: cache[b'key'] = b'value' - In [8]: %timeit cache[b'key'] - - 100000 loops, best of 3: 11.8 µs per loop - -**Note:** Micro-benchmarks have their place but are not a substitute for real -measurements. DiskCache offers cache benchmarks to defend its performance -claims. Micro-optimizations are avoided but your mileage may vary. - -DiskCache efficiently makes gigabytes of storage space available for -caching. By leveraging rock-solid database libraries and memory-mapped files, -cache performance can match and exceed industry-standard solutions. There's no -need for a C compiler or running another process. Performance is a feature and -testing has 100% coverage with unit tests and hours of stress. - -Testimonials ------------- - -Does your company or website use `DiskCache`_? Send us a `message -`_ and let us know. - -Features --------- - -- Pure-Python -- Fully Documented -- Benchmark comparisons (alternatives, Django cache backends) -- 100% test coverage -- Hours of stress testing -- Performance matters -- Django compatible API -- Thread-safe and process-safe -- Supports multiple eviction policies (LRU and LFU included) -- Keys support "tag" metadata and eviction -- Developed on Python 3.7 -- Tested on CPython 2.7, 3.4, 3.5, 3.6, 3.7 and PyPy -- Tested on Linux, Mac OS X, and Windows -- Tested using Travis CI and AppVeyor CI - -.. image:: https://api.travis-ci.org/grantjenks/python-diskcache.svg?branch=master - :target: http://www.grantjenks.com/docs/diskcache/ - -.. image:: https://ci.appveyor.com/api/projects/status/github/grantjenks/python-diskcache?branch=master&svg=true - :target: http://www.grantjenks.com/docs/diskcache/ - -Quickstart ----------- - -Installing DiskCache is simple with -`pip `_:: - - $ pip install diskcache - -You can access documentation in the interpreter with Python's built-in help -function:: - - >>> from diskcache import Cache, FanoutCache, DjangoCache - >>> help(Cache) - >>> help(FanoutCache) - >>> help(DjangoCache) - -User Guide ----------- - -For those wanting more details, this part of the documentation describes -introduction, benchmarks, development, and API. +.. include:: ../README.rst .. toctree:: - :maxdepth: 1 + :hidden: tutorial cache-benchmarks @@ -105,41 +10,3 @@ introduction, benchmarks, development, and API. sf-python-2017-meetup-talk api development - -Reference and Indices ---------------------- - -* `DiskCache Documentation`_ -* `DiskCache at PyPI`_ -* `DiskCache at GitHub`_ -* `DiskCache Issue Tracker`_ -* :ref:`search` -* :ref:`genindex` - -.. _`DiskCache Documentation`: http://www.grantjenks.com/docs/diskcache/ -.. _`DiskCache at PyPI`: https://pypi.python.org/pypi/diskcache/ -.. _`DiskCache at GitHub`: https://github.com/grantjenks/python-diskcache/ -.. _`DiskCache Issue Tracker`: https://github.com/grantjenks/python-diskcache/issues/ - -Apache2 License ---------------- - -A large number of open source projects you find today are `GPL Licensed`_. -A project that is released as GPL cannot be used in any commercial product -without the product itself also being offered as open source. - -The MIT, BSD, ISC, and Apache2 licenses are great alternatives to the GPL -that allow your open-source software to be used freely in proprietary, -closed-source software. - -DiskCache is released under terms of the `Apache2 License`_. - -.. _`GPL Licensed`: http://www.opensource.org/licenses/gpl-license.php -.. _`Apache2 License`: http://opensource.org/licenses/Apache-2.0 - -DiskCache License ------------------ - -.. include:: ../LICENSE - -.. _`DiskCache`: http://www.grantjenks.com/docs/diskcache/ From 20345385e6c01c49b59cbedb27eea05009f0f324 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 19 Mar 2019 22:43:13 -0700 Subject: [PATCH 053/329] Move benchmark settings to separate file --- tests/benchmark_djangocache.py | 2 +- tests/settings.py | 31 --------------------------- tests/settings_benchmark.py | 39 ++++++++++++++++++++++++++++++++++ tox.ini | 2 -- 4 files changed, 40 insertions(+), 34 deletions(-) create mode 100644 tests/settings_benchmark.py diff --git a/tests/benchmark_djangocache.py b/tests/benchmark_djangocache.py index 4a0c81b..898188f 100644 --- a/tests/benchmark_djangocache.py +++ b/tests/benchmark_djangocache.py @@ -32,7 +32,7 @@ def setup(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings_benchmark') import django django.setup() diff --git a/tests/settings.py b/tests/settings.py index 12b65e2..1a2f569 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -131,35 +131,4 @@ 'BACKEND': 'diskcache.DjangoCache', 'LOCATION': CACHE_DIR, }, - 'memcached': { - 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', - 'LOCATION': '127.0.0.1:11211', - }, - 'redis': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': 'redis://127.0.0.1:6379/1', - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - } - }, - 'filebased': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': '/tmp/django_cache', - 'OPTIONS': { - 'CULL_FREQUENCY': 10, - 'MAX_ENTRIES': 1000, - } - }, - 'locmem': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'diskcache', - 'OPTIONS': { - 'CULL_FREQUENCY': 10, - 'MAX_ENTRIES': 1000, - } - }, - 'diskcache': { - 'BACKEND': 'diskcache.DjangoCache', - 'LOCATION': 'tmp', - }, } diff --git a/tests/settings_benchmark.py b/tests/settings_benchmark.py new file mode 100644 index 0000000..5b614a5 --- /dev/null +++ b/tests/settings_benchmark.py @@ -0,0 +1,39 @@ +from .settings import * + +CACHES = { + 'default': { + 'BACKEND': 'diskcache.DjangoCache', + 'LOCATION': CACHE_DIR, + }, + 'memcached': { + 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', + 'LOCATION': '127.0.0.1:11211', + }, + 'redis': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/1', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + } + }, + 'filebased': { + 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + 'LOCATION': '/tmp/django_cache', + 'OPTIONS': { + 'CULL_FREQUENCY': 10, + 'MAX_ENTRIES': 1000, + } + }, + 'locmem': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'diskcache', + 'OPTIONS': { + 'CULL_FREQUENCY': 10, + 'MAX_ENTRIES': 1000, + } + }, + 'diskcache': { + 'BACKEND': 'diskcache.DjangoCache', + 'LOCATION': 'tmp', + }, +} diff --git a/tox.ini b/tox.ini index 86126ce..080f92d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,9 +5,7 @@ skip_missing_interpreters=True [testenv] deps= django==1.11.* - django_redis mock - pylibmc pytest pytest-django commands=python -m pytest From 08f3afa4f119436bef8b77f8e7ed0ef2adfcb38e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 15 Mar 2019 15:30:04 -0700 Subject: [PATCH 054/329] Initial prototype/testing for transactions --- diskcache/core.py | 97 +++++++++++++---------------------------------- 1 file changed, 26 insertions(+), 71 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 452d61c..b0b8b92 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -372,6 +372,7 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): self._directory = directory self._timeout = 0 # Manually handle retries during initialization. self._local = threading.local() + self._transaction_identifier = None if not op.isdir(directory): try: @@ -607,21 +608,34 @@ def _transact(self, filename=None): sql = self._sql filenames = [] _disk_remove = self._disk.remove + thread_identifier = threading.get_ident() + transaction_identifier = self._transaction_identifier - try: - sql('BEGIN IMMEDIATE') - except sqlite3.OperationalError: - if filename is not None: - _disk_remove(filename) - raise Timeout + if thread_identifier == transaction_identifier: + begin = False + else: + try: + sql('BEGIN IMMEDIATE') + begin = True + self._transaction_identifier = thread_identifier + except sqlite3.OperationalError: + if filename is not None: + _disk_remove(filename) + raise Timeout try: yield sql, filenames.append except BaseException: - sql('ROLLBACK') + if begin: + assert self._transaction_identifier == thread_identifier + self._transaction_identifier = None + sql('ROLLBACK') raise else: - sql('COMMIT') + if begin: + assert self._transaction_identifier == thread_identifier + self._transaction_identifier = None + sql('COMMIT') for name in filenames: if name is not None: _disk_remove(name) @@ -847,70 +861,11 @@ def add(self, key, value, expire=None, read=False, tag=None): def incr(self, key, delta=1, default=0): - """Increment value by delta for item with key. - - If key is missing and default is None then raise KeyError. Else if key - is missing and default is not None then use default for value. - - Operation is atomic. All concurrent increment operations will be - counted individually. - - Assumes value may be stored in a SQLite column. Most builds that target - machines with 64-bit pointer widths will support 64-bit signed - integers. - - :param key: key for item - :param int delta: amount to increment (default 1) - :param int default: value if key is missing (default 0) - :return: new value for item - :raises KeyError: if key is not found and default is None - :raises Timeout: if database timeout expires - - """ - now = time.time() - db_key, raw = self._disk.put(key) - select = ( - 'SELECT rowid, expire_time, filename, value FROM Cache' - ' WHERE key = ? AND raw = ?' - ) - - with self._transact() as (sql, cleanup): - rows = sql(select, (db_key, raw)).fetchall() - - if not rows: - if default is None: - raise KeyError(key) - - value = default + delta - columns = (None, None) + self._disk.store(value, False, key=key) - self._row_insert(db_key, raw, now, columns) - self._cull(now, sql, cleanup) - return value - - (rowid, expire_time, filename, value), = rows - - if expire_time is not None and expire_time < now: - if default is None: - raise KeyError(key) - - value = default + delta - columns = (None, None) + self._disk.store(value, False, key=key) - self._row_update(rowid, now, columns) - self._cull(now, sql, cleanup) - cleanup(filename) - return value - + with self._transact(): + value = self.get(key, default=default) value += delta - - columns = 'store_time = ?, value = ?' - update_column = EVICTION_POLICY[self.eviction_policy]['get'] - - if update_column is not None: - columns += ', ' + update_column.format(now=now) - - update = 'UPDATE Cache SET %s WHERE rowid = ?' % columns - sql(update, (now, value, rowid)) - + time.sleep(0.001) + self.set(key, value) return value From e967a66435582f696881b9c1f6c48af1a73996b3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Mar 2019 20:10:59 -0700 Subject: [PATCH 055/329] Replace incr with faster implementation --- diskcache/core.py | 67 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index b0b8b92..9702502 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -861,11 +861,70 @@ def add(self, key, value, expire=None, read=False, tag=None): def incr(self, key, delta=1, default=0): - with self._transact(): - value = self.get(key, default=default) + """Increment value by delta for item with key. + + If key is missing and default is None then raise KeyError. Else if key + is missing and default is not None then use default for value. + + Operation is atomic. All concurrent increment operations will be + counted individually. + + Assumes value may be stored in a SQLite column. Most builds that target + machines with 64-bit pointer widths will support 64-bit signed + integers. + + :param key: key for item + :param int delta: amount to increment (default 1) + :param int default: value if key is missing (default 0) + :return: new value for item + :raises KeyError: if key is not found and default is None + :raises Timeout: if database timeout expires + + """ + now = time.time() + db_key, raw = self._disk.put(key) + select = ( + 'SELECT rowid, expire_time, filename, value FROM Cache' + ' WHERE key = ? AND raw = ?' + ) + + with self._transact() as (sql, cleanup): + rows = sql(select, (db_key, raw)).fetchall() + + if not rows: + if default is None: + raise KeyError(key) + + value = default + delta + columns = (None, None) + self._disk.store(value, False, key=key) + self._row_insert(db_key, raw, now, columns) + self._cull(now, sql, cleanup) + return value + + (rowid, expire_time, filename, value), = rows + + if expire_time is not None and expire_time < now: + if default is None: + raise KeyError(key) + + value = default + delta + columns = (None, None) + self._disk.store(value, False, key=key) + self._row_update(rowid, now, columns) + self._cull(now, sql, cleanup) + cleanup(filename) + return value + value += delta - time.sleep(0.001) - self.set(key, value) + + columns = 'store_time = ?, value = ?' + update_column = EVICTION_POLICY[self.eviction_policy]['get'] + + if update_column is not None: + columns += ', ' + update_column.format(now=now) + + update = 'UPDATE Cache SET %s WHERE rowid = ?' % columns + sql(update, (now, value, rowid)) + return value From b41c965c9bde17f6065987247fb7eda42909ad9b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Mar 2019 21:47:57 -0700 Subject: [PATCH 056/329] Add getsetdel test to fanout cache --- tests/test_fanout.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 6d5b4f1..63d7d61 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -250,6 +250,52 @@ def test_incr_concurrent(): shutil.rmtree('tmp', ignore_errors=True) +def test_getsetdel(cache): + values = [ + (None, False), + ((None,) * 2 ** 10, False), + (1234, False), + (2 ** 512, False), + (56.78, False), + (u'hello', False), + (u'hello' * 2 ** 10, False), + (b'world', False), + (b'world' * 2 ** 10, False), + (io.BytesIO(b'world' * 2 ** 10), True), + ] + + for key, (value, file_like) in enumerate(values): + assert cache.set(key, value, read=file_like) + + assert len(cache) == len(values) + + for key, (value, file_like) in enumerate(values): + if file_like: + assert cache[key] == value.getvalue() + else: + assert cache[key] == value + + for key, _ in enumerate(values): + del cache[key] + + assert len(cache) == 0 + + for value, (key, _) in enumerate(values): + cache[key] = value + + assert len(cache) == len(values) + + for value, (key, _) in enumerate(values): + assert cache[key] == value + + for _, (key, _) in enumerate(values): + del cache[key] + + assert len(cache) == 0 + + cache.check() + + def test_get_timeout(cache): cache.set(0, 0) From 88c1da99d94bcb7e72dd18c052661d9adebb107b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Mar 2019 21:55:09 -0700 Subject: [PATCH 057/329] Add transact API with retry --- diskcache/core.py | 53 +++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 9702502..d2d8081 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -372,7 +372,7 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): self._directory = directory self._timeout = 0 # Manually handle retries during initialization. self._local = threading.local() - self._transaction_identifier = None + self._txn_id = None if not op.isdir(directory): try: @@ -604,37 +604,50 @@ def _execute_with_retry(statement, *args, **kwargs): @cl.contextmanager - def _transact(self, filename=None): + def transact(self, retry=False): + """Lock the cache to perform a transaction. + + """ + with self._transact(retry=retry): + yield + + + @cl.contextmanager + def _transact(self, retry=False, filename=None): sql = self._sql filenames = [] _disk_remove = self._disk.remove - thread_identifier = threading.get_ident() - transaction_identifier = self._transaction_identifier + tid = threading.get_ident() + txn_id = self._txn_id - if thread_identifier == transaction_identifier: + if tid == txn_id: begin = False else: - try: - sql('BEGIN IMMEDIATE') - begin = True - self._transaction_identifier = thread_identifier - except sqlite3.OperationalError: - if filename is not None: - _disk_remove(filename) - raise Timeout + while True: + try: + sql('BEGIN IMMEDIATE') + begin = True + self._txn_id = tid + break + except sqlite3.OperationalError: + if retry: + continue + if filename is not None: + _disk_remove(filename) + raise Timeout try: yield sql, filenames.append except BaseException: if begin: - assert self._transaction_identifier == thread_identifier - self._transaction_identifier = None + assert self._txn_id == tid + self._txn_id = None sql('ROLLBACK') raise else: if begin: - assert self._transaction_identifier == thread_identifier - self._transaction_identifier = None + assert self._txn_id == tid + self._txn_id = None sql('COMMIT') for name in filenames: if name is not None: @@ -684,7 +697,7 @@ def set(self, key, value, expire=None, read=False, tag=None): # INSERT OR REPLACE aka UPSERT is not used because the old filename may # need cleanup. - with self._transact(filename) as (sql, cleanup): + with self._transact(False, filename) as (sql, cleanup): rows = sql( 'SELECT rowid, filename FROM Cache' ' WHERE key = ? AND raw = ?', @@ -836,7 +849,7 @@ def add(self, key, value, expire=None, read=False, tag=None): size, mode, filename, db_value = self._disk.store(value, read, key=key) columns = (expire_time, tag, size, mode, filename, db_value) - with self._transact(filename) as (sql, cleanup): + with self._transact(False, filename) as (sql, cleanup): rows = sql( 'SELECT rowid, filename, expire_time FROM Cache' ' WHERE key = ? AND raw = ?', @@ -1261,7 +1274,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, ' ORDER BY key %s LIMIT 1' ) % order[side] - with self._transact(filename) as (sql, cleanup): + with self._transact(False, filename) as (sql, cleanup): rows = sql(select, (min_key, max_key, raw)).fetchall() if rows: From e4589f925ae6d1119f046e267cc1be8a993d0bfa Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Mar 2019 22:16:44 -0700 Subject: [PATCH 058/329] Change 'timeout expires' to 'timeout occurs' --- diskcache/core.py | 40 ++++++++++++++++++++-------------------- diskcache/djangocache.py | 18 +++++++++--------- diskcache/fanout.py | 22 +++++++++++----------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index d2d8081..90ec58f 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -667,7 +667,7 @@ def set(self, key, value, expire=None, read=False, tag=None): :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) :return: True if item was set - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ now = time.time() @@ -840,7 +840,7 @@ def add(self, key, value, expire=None, read=False, tag=None): :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) :return: True if item was added - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ now = time.time() @@ -891,7 +891,7 @@ def incr(self, key, delta=1, default=0): :param int default: value if key is missing (default 0) :return: new value for item :raises KeyError: if key is not found and default is None - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ now = time.time() @@ -962,7 +962,7 @@ def decr(self, key, delta=1, default=0): :param int default: value if key is missing (default 0) :return: new value for item :raises KeyError: if key is not found and default is None - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ return self.incr(key, -delta, default) @@ -979,7 +979,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False): (default False) :param bool tag: if True, return tag in tuple (default False) :return: value for item or default if key not found - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ db_key, raw = self._disk.put(key) @@ -1066,7 +1066,7 @@ def __getitem__(self, key): :param key: key matching item :return: corresponding value :raises KeyError: if key is not found - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ value = self.get(key, default=ENOVAL) @@ -1081,7 +1081,7 @@ def read(self, key): :param key: key matching item :return: file open for reading in binary mode :raises KeyError: if key is not found - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ handle = self.get(key, default=ENOVAL, read=True) @@ -1123,7 +1123,7 @@ def pop(self, key, default=None, expire_time=False, tag=False): (default False) :param bool tag: if True, return tag in tuple (default False) :return: value for item or default if key not found - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ db_key, raw = self._disk.put(key) @@ -1175,7 +1175,7 @@ def __delitem__(self, key): :param key: key matching item :raises KeyError: if key is not found - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ db_key, raw = self._disk.put(key) @@ -1205,7 +1205,7 @@ def delete(self, key): :param key: key matching item :return: True if item was deleted - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ try: @@ -1252,7 +1252,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) :return: key for item in cache - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ if prefix is None: @@ -1352,7 +1352,7 @@ def pull(self, prefix=None, default=(None, None), side='front', (default False) :param bool tag: if True, return tag in tuple (default False) :return: key and value item pair or default if queue is empty - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ if prefix is None: @@ -1425,7 +1425,7 @@ def check(self, fix=False): :param bool fix: correct inconsistencies :return: list of warnings - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ # pylint: disable=access-member-before-definition,W0201 @@ -1543,7 +1543,7 @@ def create_tag_index(self): It is better to initialize cache with `tag_index=True` than use this. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ sql = self._sql @@ -1554,7 +1554,7 @@ def create_tag_index(self): def drop_tag_index(self): """Drop tag index on cache database. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ sql = self._sql @@ -1574,7 +1574,7 @@ def evict(self, tag): :param str tag: tag identifying items :return: count of rows removed - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ select = ( @@ -1598,7 +1598,7 @@ def expire(self, now=None): :param float now: current time (default None, ``time.time()`` used) :return: count of items removed - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ select = ( @@ -1621,7 +1621,7 @@ def cull(self): exception occurred. :return: count of items removed - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ now = time.time() @@ -1673,7 +1673,7 @@ def clear(self): exception occurred. :return: count of rows removed - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ select = ( @@ -1912,7 +1912,7 @@ def reset(self, key, value=ENOVAL, update=True): :param value: value for item (optional) :param bool update: update database Settings table (default True) :return: updated value for item - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ sql = self._sql diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 3ec07da..e15c576 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -70,7 +70,7 @@ def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, :param int version: key version number (default None, cache parameter) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: True if item was added """ @@ -93,7 +93,7 @@ def get(self, key, default=None, version=None, read=False, :param float expire_time: if True, return expire_time in tuple (default False) :param tag: if True, return tag in tuple (default False) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: value for item if key is found else default """ @@ -127,7 +127,7 @@ def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, :param int version: key version number (default None, cache parameter) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: True if item was set """ @@ -151,7 +151,7 @@ def pop(self, key, default=None, version=None, expire_time=False, :param float expire_time: if True, return expire_time in tuple (default False) :param tag: if True, return tag in tuple (default False) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: value for item if key is found else default """ @@ -164,7 +164,7 @@ def delete(self, key, version=None, retry=True): :param key: key for item :param int version: key version number (default None, cache parameter) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: True if item was deleted """ @@ -190,7 +190,7 @@ def incr(self, key, delta=1, version=None, default=None, retry=True): :param int delta: amount to increment (default 1) :param int version: key version number (default None, cache parameter) :param int default: value if key is missing (default None) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: new value for item on success else None :raises ValueError: if key is not found and default is None @@ -223,7 +223,7 @@ def decr(self, key, delta=1, version=None, default=None, retry=True): :param int delta: amount to decrement (default 1) :param int version: key version number (default None, cache parameter) :param int default: value if key is missing (default None) - :param bool retry: retry if database timeout expires (default True) + :param bool retry: retry if database timeout occurs (default True) :return: new value for item on success else None :raises ValueError: if key is not found and default is None @@ -269,7 +269,7 @@ def create_tag_index(self): It is better to initialize cache with `tag_index=True` than use this. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ self._cache.create_tag_index() @@ -278,7 +278,7 @@ def create_tag_index(self): def drop_tag_index(self): """Drop tag index on cache database. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ self._cache.drop_tag_index() diff --git a/diskcache/fanout.py b/diskcache/fanout.py index cfa2ccf..948497d 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -67,7 +67,7 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): (default None, no expiry) :param bool read: read value as raw bytes from file (default False) :param str tag: text to associate with key (default None) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: True if item was set """ @@ -113,7 +113,7 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): (default None, no expiry) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: True if item was added """ @@ -146,7 +146,7 @@ def incr(self, key, delta=1, default=0, retry=False): :param key: key for item :param int delta: amount to increment (default 1) :param int default: value if key is missing (default 0) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: new value for item on success else None :raises KeyError: if key is not found and default is None @@ -183,7 +183,7 @@ def decr(self, key, delta=1, default=0, retry=False): :param key: key for item :param int delta: amount to decrement (default 1) :param int default: value if key is missing (default 0) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: new value for item on success else None :raises KeyError: if key is not found and default is None @@ -205,7 +205,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, :param float expire_time: if True, return expire_time in tuple (default False) :param tag: if True, return tag in tuple (default False) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: value for item if key is found else default """ @@ -281,7 +281,7 @@ def pop(self, key, default=None, expire_time=False, tag=False, :param float expire_time: if True, return expire_time in tuple (default False) :param tag: if True, return tag in tuple (default False) - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: value for item if key is found else default """ @@ -309,7 +309,7 @@ def delete(self, key, retry=False): `True` (default `False`). :param key: key for item - :param bool retry: retry if database timeout expires (default False) + :param bool retry: retry if database timeout occurs (default False) :return: True if item was deleted """ @@ -359,7 +359,7 @@ def check(self, fix=False): :param bool fix: correct inconsistencies :return: list of warnings - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ return sum((shard.check(fix=fix) for shard in self._shards), []) @@ -379,7 +379,7 @@ def create_tag_index(self): It is better to initialize cache with `tag_index=True` than use this. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ for shard in self._shards: @@ -389,7 +389,7 @@ def create_tag_index(self): def drop_tag_index(self): """Drop tag index on cache database. - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ for shard in self._shards: @@ -518,7 +518,7 @@ def reset(self, key, value=ENOVAL): :param str key: Settings key for item :param value: value for item (optional) :return: updated value for item - :raises Timeout: if database timeout expires + :raises Timeout: if database timeout occurs """ for shard in self._shards: From 7487054fab527bf8cde02420faac923389e499ae Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Mar 2019 22:46:31 -0700 Subject: [PATCH 059/329] Add retry param to Cache methods --- diskcache/core.py | 150 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 37 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 90ec58f..8fb2211 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -607,6 +607,8 @@ def _execute_with_retry(statement, *args, **kwargs): def transact(self, retry=False): """Lock the cache to perform a transaction. + # TODO + """ with self._transact(retry=retry): yield @@ -654,18 +656,22 @@ def _transact(self, retry=False, filename=None): _disk_remove(name) - def set(self, key, value, expire=None, read=False, tag=None): + def set(self, key, value, expire=None, read=False, tag=None, retry=False): """Set `key` and `value` item in cache. When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param value: value for item :param float expire: seconds until item expires (default None, no expiry) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) + :param bool retry: retry if database timeout occurs (default False) :return: True if item was set :raises Timeout: if database timeout occurs @@ -697,7 +703,7 @@ def set(self, key, value, expire=None, read=False, tag=None): # INSERT OR REPLACE aka UPSERT is not used because the old filename may # need cleanup. - with self._transact(False, filename) as (sql, cleanup): + with self._transact(retry, filename) as (sql, cleanup): rows = sql( 'SELECT rowid, filename FROM Cache' ' WHERE key = ? AND raw = ?', @@ -716,7 +722,16 @@ def set(self, key, value, expire=None, read=False, tag=None): return True - __setitem__ = set + def __setitem__(self, key, value): + """Set corresponding `value` for `key` in cache. + + :param key: key for item + :param value: value for item + :return: corresponding value + :raises KeyError: if key is not found + + """ + self.set(key, value, retry=True) def _row_update(self, rowid, now, columns): @@ -822,7 +837,7 @@ def _cull(self, now, sql, cleanup, limit=None): cleanup(filename) - def add(self, key, value, expire=None, read=False, tag=None): + def add(self, key, value, expire=None, read=False, tag=None, retry=False): """Add `key` and `value` item to cache. Similar to `set`, but only add to cache if key not present. @@ -833,12 +848,16 @@ def add(self, key, value, expire=None, read=False, tag=None): When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param value: value for item :param float expire: seconds until the key expires (default None, no expiry) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) + :param bool retry: retry if database timeout occurs (default False) :return: True if item was added :raises Timeout: if database timeout occurs @@ -849,7 +868,7 @@ def add(self, key, value, expire=None, read=False, tag=None): size, mode, filename, db_value = self._disk.store(value, read, key=key) columns = (expire_time, tag, size, mode, filename, db_value) - with self._transact(False, filename) as (sql, cleanup): + with self._transact(retry, filename) as (sql, cleanup): rows = sql( 'SELECT rowid, filename, expire_time FROM Cache' ' WHERE key = ? AND raw = ?', @@ -873,7 +892,7 @@ def add(self, key, value, expire=None, read=False, tag=None): return True - def incr(self, key, delta=1, default=0): + def incr(self, key, delta=1, default=0, retry=False): """Increment value by delta for item with key. If key is missing and default is None then raise KeyError. Else if key @@ -886,9 +905,13 @@ def incr(self, key, delta=1, default=0): machines with 64-bit pointer widths will support 64-bit signed integers. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param int delta: amount to increment (default 1) :param int default: value if key is missing (default 0) + :param bool retry: retry if database timeout occurs (default False) :return: new value for item :raises KeyError: if key is not found and default is None :raises Timeout: if database timeout occurs @@ -901,7 +924,7 @@ def incr(self, key, delta=1, default=0): ' WHERE key = ? AND raw = ?' ) - with self._transact() as (sql, cleanup): + with self._transact(retry) as (sql, cleanup): rows = sql(select, (db_key, raw)).fetchall() if not rows: @@ -941,7 +964,7 @@ def incr(self, key, delta=1, default=0): return value - def decr(self, key, delta=1, default=0): + def decr(self, key, delta=1, default=0, retry=False): """Decrement value by delta for item with key. If key is missing and default is None then raise KeyError. Else if key @@ -957,20 +980,28 @@ def decr(self, key, delta=1, default=0): machines with 64-bit pointer widths will support 64-bit signed integers. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param int delta: amount to decrement (default 1) :param int default: value if key is missing (default 0) + :param bool retry: retry if database timeout occurs (default False) :return: new value for item :raises KeyError: if key is not found and default is None :raises Timeout: if database timeout occurs """ - return self.incr(key, -delta, default) + return self.incr(key, -delta, default, retry) - def get(self, key, default=None, read=False, expire_time=False, tag=False): + def get(self, key, default=None, read=False, expire_time=False, tag=False, + retry=False): """Retrieve value from cache. If `key` is missing, return `default`. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param default: value to return if key is missing (default None) :param bool read: if True, return file handle to value @@ -978,6 +1009,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False): :param bool expire_time: if True, return expire_time in tuple (default False) :param bool tag: if True, return tag in tuple (default False) + :param bool retry: retry if database timeout occurs (default False) :return: value for item or default if key not found :raises Timeout: if database timeout occurs @@ -1019,7 +1051,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False): 'UPDATE Settings SET value = value + 1 WHERE key = "misses"' ) - with self._transact() as (sql, _): + with self._transact(retry) as (sql, _): rows = sql(select, (db_key, raw, time.time())).fetchall() if not rows: @@ -1066,25 +1098,28 @@ def __getitem__(self, key): :param key: key matching item :return: corresponding value :raises KeyError: if key is not found - :raises Timeout: if database timeout occurs """ - value = self.get(key, default=ENOVAL) + value = self.get(key, default=ENOVAL, retry=True) if value is ENOVAL: raise KeyError(key) return value - def read(self, key): + def read(self, key, retry=False): """Return file handle value corresponding to `key` from cache. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key matching item + :param bool retry: retry if database timeout occurs (default False) :return: file open for reading in binary mode :raises KeyError: if key is not found :raises Timeout: if database timeout occurs """ - handle = self.get(key, default=ENOVAL, read=True) + handle = self.get(key, default=ENOVAL, read=True, retry=retry) if handle is ENOVAL: raise KeyError(key) return handle @@ -1110,18 +1145,22 @@ def __contains__(self, key): return bool(rows) - def pop(self, key, default=None, expire_time=False, tag=False): + def pop(self, key, default=None, expire_time=False, tag=False, retry=False): """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. Operation is atomic. Concurrent operations will be serialized. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key for item :param default: value to return if key is missing (default None) :param bool expire_time: if True, return expire_time in tuple (default False) :param bool tag: if True, return tag in tuple (default False) + :param bool retry: retry if database timeout occurs (default False) :return: value for item or default if key not found :raises Timeout: if database timeout occurs @@ -1138,7 +1177,7 @@ def pop(self, key, default=None, expire_time=False, tag=False): elif expire_time or tag: default = default, None - with self._transact() as (sql, _): + with self._transact(retry) as (sql, _): rows = sql(select, (db_key, raw, time.time())).fetchall() if not rows: @@ -1170,17 +1209,21 @@ def pop(self, key, default=None, expire_time=False, tag=False): return value - def __delitem__(self, key): + def __delitem__(self, key, retry=True): """Delete corresponding item for `key` from cache. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default `True`). + :param key: key matching item + :param bool retry: retry if database timeout occurs (default True) :raises KeyError: if key is not found :raises Timeout: if database timeout occurs """ db_key, raw = self._disk.put(key) - with self._transact() as (sql, cleanup): + with self._transact(retry) as (sql, cleanup): rows = sql( 'SELECT rowid, filename FROM Cache' ' WHERE key = ? AND raw = ?' @@ -1198,24 +1241,28 @@ def __delitem__(self, key): return True - def delete(self, key): + def delete(self, key, retry=False): """Delete corresponding item for `key` from cache. Missing keys are ignored. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param key: key matching item + :param bool retry: retry if database timeout occurs (default False) :return: True if item was deleted :raises Timeout: if database timeout occurs """ try: - return self.__delitem__(key) + return self.__delitem__(key, retry=retry) except KeyError: return False def push(self, value, prefix=None, side='back', expire=None, read=False, - tag=None): + tag=None, retry=False): """Push `value` onto `side` of queue identified by `prefix` in cache. When prefix is None, integer keys are used. Otherwise, string keys are @@ -1229,6 +1276,9 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + See also `Cache.pull`. >>> cache = Cache('/tmp/test') @@ -1251,6 +1301,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, (default None, no expiry) :param bool read: read value as bytes from file (default False) :param str tag: text to associate with key (default None) + :param bool retry: retry if database timeout occurs (default False) :return: key for item in cache :raises Timeout: if database timeout occurs @@ -1274,7 +1325,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, ' ORDER BY key %s LIMIT 1' ) % order[side] - with self._transact(False, filename) as (sql, cleanup): + with self._transact(retry, filename) as (sql, cleanup): rows = sql(select, (min_key, max_key, raw)).fetchall() if rows: @@ -1305,7 +1356,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, def pull(self, prefix=None, default=(None, None), side='front', - expire_time=False, tag=False): + expire_time=False, tag=False, retry=False): """Pull key and value item pair from `side` of queue in cache. When prefix is None, integer keys are used. Otherwise, string keys are @@ -1319,6 +1370,9 @@ def pull(self, prefix=None, default=(None, None), side='front', Operation is atomic. Concurrent operations will be serialized. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + See also `Cache.push` and `Cache.get`. >>> cache = Cache('/tmp/test') @@ -1351,6 +1405,7 @@ def pull(self, prefix=None, default=(None, None), side='front', :param bool expire_time: if True, return expire_time in tuple (default False) :param bool tag: if True, return tag in tuple (default False) + :param bool retry: retry if database timeout occurs (default False) :return: key and value item pair or default if queue is empty :raises Timeout: if database timeout occurs @@ -1375,7 +1430,7 @@ def pull(self, prefix=None, default=(None, None), side='front', default = default, None while True: - with self._transact() as (sql, cleanup): + with self._transact(retry) as (sql, cleanup): rows = sql(select, (min_key, max_key)).fetchall() if not rows: @@ -1412,7 +1467,7 @@ def pull(self, prefix=None, default=(None, None), side='front', return key, value - def check(self, fix=False): + def check(self, fix=False, retry=False): """Check database and file system consistency. Intended for use in testing and post-mortem error analysis. @@ -1423,7 +1478,11 @@ def check(self, fix=False): held for a long time. For example, local benchmarking shows that a cache with 1,000 file references takes ~60ms to check. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param bool fix: correct inconsistencies + :param bool retry: retry if database timeout occurs (default False) :return: list of warnings :raises Timeout: if database timeout occurs @@ -1443,7 +1502,7 @@ def check(self, fix=False): if fix: sql('VACUUM') - with self._transact() as (sql, _): + with self._transact(retry) as (sql, _): # Check Cache.filename against file system. @@ -1562,7 +1621,7 @@ def drop_tag_index(self): self.reset('tag_index', 0) - def evict(self, tag): + def evict(self, tag, retry=False): """Remove items with matching `tag` from cache. Removing items is an iterative process. In each iteration, a subset of @@ -1572,7 +1631,11 @@ def evict(self, tag): `args` attribute will be the number of items removed before the exception occurred. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param str tag: tag identifying items + :param bool retry: retry if database timeout occurs (default False) :return: count of rows removed :raises Timeout: if database timeout occurs @@ -1583,10 +1646,10 @@ def evict(self, tag): ' ORDER BY rowid LIMIT ?' ) args = [tag, 0, 100] - return self._select_delete(select, args, arg_index=1) + return self._select_delete(select, args, arg_index=1, retry=retry) - def expire(self, now=None): + def expire(self, now=None, retry=False): """Remove expired items from cache. Removing items is an iterative process. In each iteration, a subset of @@ -1596,7 +1659,11 @@ def expire(self, now=None): `args` attribute will be the number of items removed before the exception occurred. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + :param float now: current time (default None, ``time.time()`` used) + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed :raises Timeout: if database timeout occurs @@ -1607,10 +1674,10 @@ def expire(self, now=None): ' ORDER BY expire_time LIMIT ?' ) args = [0, now or time.time(), 100] - return self._select_delete(select, args, row_index=1) + return self._select_delete(select, args, row_index=1, retry=retry) - def cull(self): + def cull(self, retry=False): """Cull items from cache until volume is less than size limit. Removing items is an iterative process. In each iteration, a subset of @@ -1620,6 +1687,10 @@ def cull(self): `args` attribute will be the number of items removed before the exception occurred. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed :raises Timeout: if database timeout occurs @@ -1641,7 +1712,7 @@ def cull(self): try: while self.volume() > self.size_limit: - with self._transact() as (sql, cleanup): + with self._transact(retry) as (sql, cleanup): rows = sql(select_filename, (10,)).fetchall() if not rows: @@ -1662,7 +1733,7 @@ def cull(self): return count - def clear(self): + def clear(self, retry=False): """Remove all items from cache. Removing items is an iterative process. In each iteration, a subset of @@ -1672,6 +1743,10 @@ def clear(self): `args` attribute will be the number of items removed before the exception occurred. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + :param bool retry: retry if database timeout occurs (default False) :return: count of rows removed :raises Timeout: if database timeout occurs @@ -1682,16 +1757,17 @@ def clear(self): ' ORDER BY rowid LIMIT ?' ) args = [0, 100] - return self._select_delete(select, args) + return self._select_delete(select, args, retry=retry) - def _select_delete(self, select, args, row_index=0, arg_index=0): + def _select_delete(self, select, args, row_index=0, arg_index=0, + retry=False): count = 0 delete = 'DELETE FROM Cache WHERE rowid IN (%s)' try: while True: - with self._transact() as (sql, cleanup): + with self._transact(retry) as (sql, cleanup): rows = sql(select, args).fetchall() if not rows: From aeda91ea4215c652a2f4b321bbdeaf4ca0bbe33d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 21 Mar 2019 16:25:10 -0700 Subject: [PATCH 060/329] Import get_ident from thread for Python 2 --- diskcache/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index 8fb2211..3019482 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -22,6 +22,7 @@ import cPickle as pickle # pylint: disable=import-error # ISSUE #25 Fix for http://bugs.python.org/issue10211 from cStringIO import StringIO as BytesIO # pylint: disable=import-error + from thread import get_ident TextType = unicode # pylint: disable=invalid-name,undefined-variable BytesType = str INT_TYPES = int, long # pylint: disable=undefined-variable @@ -30,6 +31,7 @@ else: import pickle from io import BytesIO # pylint: disable=ungrouped-imports + from threading import get_ident TextType = str BytesType = bytes INT_TYPES = (int,) @@ -619,7 +621,7 @@ def _transact(self, retry=False, filename=None): sql = self._sql filenames = [] _disk_remove = self._disk.remove - tid = threading.get_ident() + tid = get_ident() txn_id = self._txn_id if tid == txn_id: From ffd46ba18ea76baad03e148fb9143baa20bdc975 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 21 Mar 2019 20:01:03 -0700 Subject: [PATCH 061/329] Refactor FanoutCache to use retry param from Cache --- diskcache/fanout.py | 195 ++++++++++++++++++++++--------------------- tests/test_fanout.py | 94 +-------------------- 2 files changed, 102 insertions(+), 187 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 948497d..c3bb14c 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -1,10 +1,16 @@ "Fanout cache automatically shards keys and values." import itertools as it +import operator import os.path as op import sqlite3 import time +try: + from functools import reduce +except ImportError: + reduce + from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout from .memo import memoize from .persistent import Deque, Index @@ -72,16 +78,11 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): """ index = self._hash(key) % self._count - set_func = self._shards[index].set - - while True: - try: - return set_func(key, value, expire, read, tag) - except Timeout: - if retry: - continue - else: - return False + shard = self._shards[index] + try: + return shard.set(key, value, expire, read, tag, retry) + except Timeout: + return False def __setitem__(self, key, value): @@ -93,7 +94,9 @@ def __setitem__(self, key, value): :param value: value for item """ - self.set(key, value, retry=True) + index = self._hash(key) % self._count + shard = self._shards[index] + shard[key] = value def add(self, key, value, expire=None, read=False, tag=None, retry=False): @@ -107,6 +110,9 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param value: value for item :param float expire: seconds until the key expires @@ -118,16 +124,11 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): """ index = self._hash(key) % self._count - add_func = self._shards[index].add - - while True: - try: - return add_func(key, value, expire, read, tag) - except Timeout: - if retry: - continue - else: - return False + shard = self._shards[index] + try: + return shard.add(key, value, expire, read, tag, retry) + except Timeout: + return False def incr(self, key, delta=1, default=0, retry=False): @@ -143,6 +144,9 @@ def incr(self, key, delta=1, default=0, retry=False): machines with 64-bit pointer widths will support 64-bit signed integers. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param int delta: amount to increment (default 1) :param int default: value if key is missing (default 0) @@ -152,16 +156,11 @@ def incr(self, key, delta=1, default=0, retry=False): """ index = self._hash(key) % self._count - incr_func = self._shards[index].incr - - while True: - try: - return incr_func(key, delta, default) - except Timeout: - if retry: - continue - else: - return None + shard = self._shards[index] + try: + return shard.incr(key, delta, default, retry) + except Timeout: + return None def decr(self, key, delta=1, default=0, retry=False): @@ -180,6 +179,9 @@ def decr(self, key, delta=1, default=0, retry=False): machines with 64-bit pointer widths will support 64-bit signed integers. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param int delta: amount to decrement (default 1) :param int default: value if key is missing (default 0) @@ -188,7 +190,12 @@ def decr(self, key, delta=1, default=0, retry=False): :raises KeyError: if key is not found and default is None """ - return self.incr(key, -delta, default, retry) + index = self._hash(key) % self._count + shard = self._shards[index] + try: + return shard.decr(key, delta, default, retry) + except Timeout: + return None def get(self, key, default=None, read=False, expire_time=False, tag=False, @@ -210,19 +217,11 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, """ index = self._hash(key) % self._count - get_func = self._shards[index].get - - while True: - try: - return get_func( - key, default=default, read=read, expire_time=expire_time, - tag=tag, - ) - except (Timeout, sqlite3.OperationalError): - if retry: - continue - else: - return default + shard = self._shards[index] + try: + return shard.get(key, default, read, expire_time, tag, retry) + except (Timeout, sqlite3.OperationalError): + return default def __getitem__(self, key): @@ -235,12 +234,9 @@ def __getitem__(self, key): :raises KeyError: if key is not found """ - value = self.get(key, default=ENOVAL, retry=True) - - if value is ENOVAL: - raise KeyError(key) - - return value + index = self._hash(key) % self._count + shard = self._shards[index] + return shard[key] def read(self, key): @@ -265,7 +261,8 @@ def __contains__(self, key): """ index = self._hash(key) % self._count - return key in self._shards[index] + shard = self._shards[index] + return key in shard def pop(self, key, default=None, expire_time=False, tag=False, @@ -276,6 +273,9 @@ def pop(self, key, default=None, expire_time=False, tag=False, Operation is atomic. Concurrent operations will be serialized. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param key: key for item :param default: return value if key is missing (default None) :param float expire_time: if True, return expire_time in tuple @@ -286,18 +286,11 @@ def pop(self, key, default=None, expire_time=False, tag=False, """ index = self._hash(key) % self._count - pop_func = self._shards[index].pop - - while True: - try: - return pop_func( - key, default=default, expire_time=expire_time, tag=tag, - ) - except Timeout: - if retry: - continue - else: - return default + shard = self._shards[index] + try: + return shard.pop(key, default, expire_time, tag, retry) + except Timeout: + return default def delete(self, key, retry=False): @@ -314,18 +307,11 @@ def delete(self, key, retry=False): """ index = self._hash(key) % self._count - del_func = self._shards[index].__delitem__ - - while True: - try: - return del_func(key) - except Timeout: - if retry: - continue - else: - return False - except KeyError: - return False + shard = self._shards[index] + try: + return shard.delete(key, retry) + except Timeout: + return False def __delitem__(self, key): @@ -337,16 +323,15 @@ def __delitem__(self, key): :raises KeyError: if key is not found """ - deleted = self.delete(key, retry=True) - - if not deleted: - raise KeyError(key) + index = self._hash(key) % self._count + shard = self._shards[index] + del shard[key] memoize = memoize - def check(self, fix=False): + def check(self, fix=False, retry=False): """Check database and file system consistency. Intended for use in testing and post-mortem error analysis. @@ -357,21 +342,30 @@ def check(self, fix=False): held for a long time. For example, local benchmarking shows that a cache with 1,000 file references takes ~60ms to check. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param bool fix: correct inconsistencies + :param bool retry: retry if database timeout occurs (default False) :return: list of warnings :raises Timeout: if database timeout occurs """ - return sum((shard.check(fix=fix) for shard in self._shards), []) + warnings = (shard.check(fix, retry) for shard in self._shards) + return reduce(operator.iadd, warnings, []) - def expire(self): + def expire(self, retry=False): """Remove expired items from cache. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed """ - return self._remove('expire', args=(time.time(),)) + return self._remove('expire', args=(time.time(),), retry=retry) def create_tag_index(self): @@ -396,41 +390,53 @@ def drop_tag_index(self): shard.drop_tag_index() - def evict(self, tag): + def evict(self, tag, retry=False): """Remove items with matching `tag` from cache. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + :param str tag: tag identifying items + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed """ - return self._remove('evict', args=(tag,)) + return self._remove('evict', args=(tag,), retry=retry) - def cull(self): + def cull(self, retry=False): """Cull items from cache until volume is less than size limit. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed """ - return self._remove('cull') + return self._remove('cull', retry=retry) - def clear(self): + def clear(self, retry=False): """Remove all items from cache. + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + + :param bool retry: retry if database timeout occurs (default False) :return: count of items removed """ - return self._remove('clear') + return self._remove('clear', retry=retry) - def _remove(self, name, args=()): + def _remove(self, name, args=(), retry=False): total = 0 for shard in self._shards: method = getattr(shard, name) while True: try: - count = method(*args) + count = method(*args, retry=retry) total += count except Timeout as timeout: total += timeout.args[0] @@ -448,8 +454,9 @@ def stats(self, enable=True, reset=False): """ results = [shard.stats(enable, reset) for shard in self._shards] - return (sum(result[0] for result in results), - sum(result[1] for result in results)) + total_hits = sum(hits for hits, _ in results) + total_misses = sum(misses for _, misses in results) + return total_hits, total_misses def volume(self): diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 63d7d61..77f7384 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -110,20 +110,6 @@ def test_set_timeout(cache): assert not cache.set(0, 0) -def test_set_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - set_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.set = set_func - set_func.side_effect = [dc.Timeout, True, dc.Timeout, True] - - with mock.patch.object(cache, '_shards', shards): - assert cache.set(0, 0, retry=True) - cache[1] = 1 - - def test_add(cache): assert cache.add(0, 0) assert not cache.add(0, 1) @@ -143,19 +129,6 @@ def test_add_timeout(cache): assert not cache.add(0, 0) -def test_add_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - add_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.add = add_func - add_func.side_effect = [dc.Timeout, True] - - with mock.patch.object(cache, '_shards', shards): - assert cache.add(0, 0, retry=True) - - def stress_add(cache, limit, results): total = 0 for num in range(limit): @@ -205,19 +178,6 @@ def test_incr_timeout(cache): assert cache.incr('key', 1) is None -def test_incr_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - incr_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.incr = incr_func - incr_func.side_effect = [dc.Timeout, 1] - - with mock.patch.object(cache, '_shards', shards): - assert cache.incr('key', retry=True) == 1 - - def test_decr(cache): cache.decr('key', delta=2) == -2 @@ -311,19 +271,6 @@ def test_get_timeout(cache): assert cache.get(0) is None -def test_get_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - get_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.get = get_func - get_func.side_effect = [dc.Timeout, 0] - - with mock.patch.object(cache, '_shards', shards): - assert cache.get(0, retry=True) == 0 - - def test_pop(cache): for num in range(100): cache[num] = num @@ -345,45 +292,19 @@ def test_pop_timeout(cache): assert cache.pop(0) is None -def test_pop_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - pop_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.pop = pop_func - pop_func.side_effect = [dc.Timeout, 0] - - with mock.patch.object(cache, '_shards', shards): - assert cache.pop(0, retry=True) == 0 - - def test_delete_timeout(cache): shards = mock.Mock() shard = mock.Mock() delete_func = mock.Mock() shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.__delitem__ = delete_func + shard.delete = delete_func delete_func.side_effect = dc.Timeout with mock.patch.object(cache, '_shards', shards): assert not cache.delete(0) -def test_delete_timeout_retry(cache): - shards = mock.Mock() - shard = mock.Mock() - delete_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.__delitem__ = delete_func - delete_func.side_effect = [dc.Timeout, True] - - with mock.patch.object(cache, '_shards', shards): - assert cache.delete(0, retry=True) - - def test_delitem(cache): cache[0] = 0 assert cache[0] == 0 @@ -395,19 +316,6 @@ def test_delitem_keyerror(cache): del cache[0] -def test_delitem_timeout(cache): - shards = mock.Mock() - shard = mock.Mock() - delete_func = mock.Mock() - - shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) - shard.__delitem__ = delete_func - delete_func.side_effect = [dc.Timeout, True] - - with mock.patch.object(cache, '_shards', shards): - del cache[0] - - def test_tag_index(cache): assert cache.tag_index == 0 cache.create_tag_index() From 10ba4e6773a19db490a898c4782e511267815c3d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 21 Mar 2019 21:38:10 -0700 Subject: [PATCH 062/329] Update persistent data types to use retry param of Cache --- diskcache/persistent.py | 195 +++++++++------------------------------- tests/test_deque.py | 104 --------------------- tests/test_index.py | 98 -------------------- 3 files changed, 41 insertions(+), 356 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 1bd0388..a8d7485 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -10,7 +10,7 @@ from shutil import rmtree from tempfile import mkdtemp -from .core import BytesType, Cache, ENOVAL, TextType, Timeout +from .core import BytesType, Cache, ENOVAL, TextType ############################################################################ # BEGIN Python 2/3 Shims @@ -193,7 +193,7 @@ def __getitem__(self, index): try: key = _key(index) return _cache[key] - except (KeyError, Timeout): + except KeyError: continue @@ -218,14 +218,8 @@ def __setitem__(self, index, value): """ _key = self._key _cache = self._cache - - while True: - try: - key = _key(index) - _cache[key] = value - return - except Timeout: - continue + key = _key(index) + _cache[key] = value def __delitem__(self, index): @@ -254,7 +248,7 @@ def __delitem__(self, index): key = _key(index) del _cache[key] return - except (KeyError, Timeout): + except KeyError: continue @@ -297,7 +291,7 @@ def __iter__(self): for key in _cache.iterkeys(): try: yield _cache[key] - except (KeyError, Timeout): + except KeyError: pass @@ -330,7 +324,7 @@ def __reversed__(self): for key in _cache.iterkeys(reverse=True): try: yield _cache[key] - except (KeyError, Timeout): + except KeyError: pass @@ -356,14 +350,7 @@ def append(self, value): :param value: value to add to back of deque """ - _cache_push = self._cache.push - - while True: - try: - _cache_push(value) - return - except Timeout: - continue + self._cache.push(value, retry=True) def appendleft(self, value): @@ -380,28 +367,14 @@ def appendleft(self, value): :param value: value to add to front of deque """ - _cache_push = self._cache.push - - while True: - try: - _cache_push(value, side='front') - return - except Timeout: - continue + self._cache.push(value, side='front', retry=True) def clear(self): """Remove all elements from deque. """ - _cache_clear = self._cache.clear - - while True: - try: - _cache_clear() - return - except Timeout: - continue + self._cache.clear(retry=True) def count(self, value): @@ -469,18 +442,11 @@ def pop(self): :raises IndexError: if deque is empty """ - _cache_pull = self._cache.pull - - while True: - try: - default = None, ENOVAL - _, value = _cache_pull(default=default, side='back') - except Timeout: - continue - else: - if value is ENOVAL: - raise IndexError('pop from an empty deque') - return value + default = None, ENOVAL + _, value = self._cache.pull(default=default, side='back', retry=True) + if value is ENOVAL: + raise IndexError('pop from an empty deque') + return value def popleft(self): @@ -499,18 +465,11 @@ def popleft(self): IndexError: pop from an empty deque """ - _cache_pull = self._cache.pull - - while True: - try: - default = None, ENOVAL - _, value = _cache_pull(default=default) - except Timeout: - continue - else: - if value is ENOVAL: - raise IndexError('pop from an empty deque') - return value + default = None, ENOVAL + _, value = self._cache.pull(default=default, retry=True) + if value is ENOVAL: + raise IndexError('pop from an empty deque') + return value def remove(self, value): @@ -538,27 +497,16 @@ def remove(self, value): for key in _cache.iterkeys(): try: - while True: - try: - item = _cache[key] - except Timeout: - continue - else: - break + item = _cache[key] except KeyError: continue else: if value == item: try: - while True: - try: - del _cache[key] - except Timeout: - continue - else: - return + del _cache[key] except KeyError: continue + return raise ValueError('deque.remove(value): value not in deque') @@ -743,13 +691,7 @@ def __getitem__(self, key): :raises KeyError: if key is not found """ - _cache = self._cache - - while True: - try: - return _cache[key] - except Timeout: - continue + return self._cache[key] def __setitem__(self, key, value): @@ -768,15 +710,7 @@ def __setitem__(self, key, value): :param value: value for item """ - _cache = self._cache - - while True: - try: - _cache[key] = value - except Timeout: - continue - else: - return + self._cache[key] = value def __delitem__(self, key): @@ -800,15 +734,7 @@ def __delitem__(self, key): :raises KeyError: if key is not found """ - _cache = self._cache - - while True: - try: - del _cache[key] - except Timeout: - continue - else: - return + del self._cache[key] def setdefault(self, key, default=None): @@ -830,18 +756,11 @@ def setdefault(self, key, default=None): """ _cache = self._cache - while True: try: - return self[key] + return _cache[key] except KeyError: - while True: - try: - _cache.add(key, default) - except Timeout: - continue - else: - break + _cache.add(key, default, retry=True) def pop(self, key, default=ENOVAL): @@ -868,18 +787,11 @@ def pop(self, key, default=ENOVAL): :raises KeyError: if key is not found and default is ENOVAL """ - _cache = self._cache - - while True: - try: - value = _cache.pop(key, default=default) - except Timeout: - continue - else: - if value is ENOVAL: - raise KeyError(key) - return value + value = _cache.pop(key, default=default, retry=True) + if value is ENOVAL: + raise KeyError(key) + return value def popitem(self, last=True): @@ -921,8 +833,8 @@ def popitem(self, last=True): raise KeyError try: - value = _cache.pop(key) - except (KeyError, Timeout): + value = _cache.pop(key, retry=True) + except KeyError: continue else: return key, value @@ -958,13 +870,7 @@ def push(self, value, prefix=None, side='back'): :return: key for item in cache """ - _cache_push = self._cache.push - - while True: - try: - return _cache_push(value, prefix, side) - except Timeout: - continue + return self._cache.push(value, prefix, side, retry=True) def pull(self, prefix=None, default=(None, None), side='front'): @@ -1006,27 +912,14 @@ def pull(self, prefix=None, default=(None, None), side='front'): :return: key and value item pair or default if queue is empty """ - _cache_pull = self._cache.pull - - while True: - try: - return _cache_pull(prefix, default, side) - except Timeout: - continue + return self._cache.pull(prefix, default, side, retry=True) def clear(self): """Remove all items from index. """ - _cache_clear = self._cache.clear - - while True: - try: - _cache_clear() - return - except Timeout: - continue + self._cache.clear(retry=True) def __iter__(self): @@ -1145,11 +1038,8 @@ def itervalues(self): try: yield _cache[key] except KeyError: - break - except Timeout: - continue - else: - break + pass + break def iteritems(self): @@ -1171,11 +1061,8 @@ def iteritems(self): try: yield key, _cache[key] except KeyError: - break - except Timeout: - continue - else: - break + pass + break def viewkeys(self): diff --git a/tests/test_deque.py b/tests/test_deque.py index f9ba35b..59d9819 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -123,82 +123,12 @@ def test_indexerror_islice(deque): deque[0] -def test_get_timeout(deque): - cache = mock.MagicMock() - cache.__len__.return_value = 1 - cache.iterkeys.side_effect = [iter([0]), iter([0])] - cache.__getitem__.side_effect = [dc.Timeout, 0] - - deque.append(0) - - with mock.patch.object(deque, '_cache', cache): - deque[0] - - -def test_set_timeout(deque): - cache = mock.MagicMock() - cache.__len__.return_value = 1 - cache.iterkeys.side_effect = [iter([0]), iter([0])] - cache.__setitem__.side_effect = [dc.Timeout, None] - - deque.append(0) - - with mock.patch.object(deque, '_cache', cache): - deque[0] = 0 - - -def test_del_timeout(deque): - cache = mock.MagicMock() - cache.__len__.return_value = 1 - cache.iterkeys.side_effect = [iter([0]), iter([0])] - cache.__delitem__.side_effect = [dc.Timeout, None] - - deque.append(0) - - with mock.patch.object(deque, '_cache', cache): - del deque[0] - - def test_repr(): directory = '/tmp/diskcache/deque' deque = dc.Deque(directory=directory) assert repr(deque) == 'Deque(directory=%r)' % directory -def test_iter_timeout(deque): - cache = mock.MagicMock() - cache.iterkeys.side_effect = [iter([0, 1])] - cache.__getitem__.side_effect = [dc.Timeout, 0] - - with mock.patch.object(deque, '_cache', cache): - assert list(deque) == [0] - - -def test_reversed_timeout(deque): - cache = mock.MagicMock() - cache.iterkeys.side_effect = [iter([0, 1])] - cache.__getitem__.side_effect = [dc.Timeout, 0] - - with mock.patch.object(deque, '_cache', cache): - assert list(reversed(deque)) == [0] - - -def test_append_timeout(deque): - cache = mock.MagicMock() - cache.push.side_effect = [dc.Timeout, None] - - with mock.patch.object(deque, '_cache', cache): - deque.append(0) - - -def test_appendleft_timeout(deque): - cache = mock.MagicMock() - cache.push.side_effect = [dc.Timeout, None] - - with mock.patch.object(deque, '_cache', cache): - deque.appendleft(0) - - def test_count(deque): deque += 'abbcccddddeeeee' @@ -231,14 +161,6 @@ def test_pop_indexerror(deque): deque.pop() -def test_pop_timeout(deque): - cache = mock.MagicMock() - cache.pull.side_effect = [dc.Timeout, (None, 0)] - - with mock.patch.object(deque, '_cache', cache): - assert deque.pop() == 0 - - def test_popleft(deque): sequence = list('abcde') deque.extend(sequence) @@ -254,14 +176,6 @@ def test_popleft_indexerror(deque): deque.popleft() -def test_popleft_timeout(deque): - cache = mock.MagicMock() - cache.pull.side_effect = [dc.Timeout, (None, 0)] - - with mock.patch.object(deque, '_cache', cache): - assert deque.popleft() == 0 - - def test_remove(deque): deque.extend('abaca') deque.remove('a') @@ -272,16 +186,6 @@ def test_remove(deque): assert deque == 'bc' -def test_remove_timeout(deque): - cache = mock.MagicMock() - cache.iterkeys.side_effect = [iter([0, 1, 2, 3, 4])] - cache.__getitem__.side_effect = [0, dc.Timeout, KeyError, 3, 3] - cache.__delitem__.side_effect = [KeyError, dc.Timeout, None] - - with mock.patch.object(deque, '_cache', cache): - deque.remove(3) - - def test_remove_valueerror(deque): with pytest.raises(ValueError): deque.remove(0) @@ -332,11 +236,3 @@ def test_rotate_indexerror_negative(deque): with mock.patch.object(deque, '_cache', cache): deque.rotate(-1) - - -def test_clear_timeout(deque): - cache = mock.MagicMock() - cache.clear.side_effect = [dc.Timeout, None] - - with mock.patch.object(deque, '_cache', cache): - deque.clear() diff --git a/tests/test_index.py b/tests/test_index.py index e5cd71d..38fb9d2 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -77,30 +77,6 @@ def test_getsetdel(index): assert len(index) == 0 -def test_get_timeout(index): - cache = mock.MagicMock() - cache.__getitem__.side_effect = [dc.Timeout, 0] - - with mock.patch.object(index, '_cache', cache): - assert index[0] == 0 - - -def test_set_timeout(index): - cache = mock.MagicMock() - cache.__setitem__.side_effect = [dc.Timeout, None] - - with mock.patch.object(index, '_cache', cache): - index[0] = 0 - - -def test_del_timeout(index): - cache = mock.MagicMock() - cache.__delitem__.side_effect = [dc.Timeout, None] - - with mock.patch.object(index, '_cache', cache): - del index[0] - - def test_pop(index): letters = 'abcde' assert len(index) == 0 @@ -121,14 +97,6 @@ def test_pop_keyerror(index): index.pop('a') -def test_pop_timeout(index): - cache = mock.MagicMock() - cache.pop.side_effect = [dc.Timeout, 1] - - with mock.patch.object(index, '_cache', cache): - assert index.pop(0) == 1 - - def test_popitem(index): letters = 'abcde' @@ -146,34 +114,11 @@ def test_popitem_keyerror(index): index.popitem() -def test_popitem_timeout(index): - cache = mock.MagicMock() - cache.__reversed__ = mock.Mock() - cache.__reversed__.side_effect = [iter([0]), iter([0])] - cache.pop.side_effect = [dc.Timeout, 1] - - with mock.patch.object(index, '_cache', cache): - value = index.popitem() - assert value == (0, 1) - - def test_setdefault(index): assert index.setdefault('a', 0) == 0 assert index.setdefault('a', 1) == 0 -def test_setdefault_timeout(index): - cache = mock.MagicMock() - cache.__getitem__ = mock.Mock() - cache.__getitem__.side_effect = [KeyError, 0] - cache.add = mock.Mock() - cache.add.side_effect = [dc.Timeout, 0] - - with mock.patch.object(index, '_cache', cache): - value = index.setdefault('a', 0) - assert value == 0 - - def test_iter(index): letters = 'abcde' @@ -201,46 +146,3 @@ def test_state(index): state = pickle.dumps(index) values = pickle.loads(state) assert values == mapping - - -def test_push_timeout(index): - cache = mock.MagicMock() - cache.push.side_effect = [dc.Timeout, None] - - with mock.patch.object(index, '_cache', cache): - index.push(0) - - -def test_pull_timeout(index): - cache = mock.MagicMock() - cache.pull.side_effect = [dc.Timeout, None] - - with mock.patch.object(index, '_cache', cache): - index.pull(0) - - -def test_clear_timeout(index): - cache = mock.MagicMock() - cache.clear.side_effect = [dc.Timeout, None] - - with mock.patch.object(index, '_cache', cache): - index.clear() - - -if sys.hexversion < 0x03000000: - def test_itervalues_timeout(index): - cache = mock.MagicMock() - cache.__iter__.side_effect = [iter([0, 1, 2])] - cache.__getitem__.side_effect = [dc.Timeout, KeyError, 1, 2] - - with mock.patch.object(index, '_cache', cache): - assert list(index.itervalues()) == [1, 2] - - - def test_iteritems_timeout(index): - cache = mock.MagicMock() - cache.__iter__.side_effect = [iter([0, 1, 2])] - cache.__getitem__.side_effect = [dc.Timeout, KeyError, 1, 2] - - with mock.patch.object(index, '_cache', cache): - assert list(index.iteritems()) == [(1, 1), (2, 2)] From d05369b66ae5319859ce1308b718fa6cda16ba73 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 21 Mar 2019 22:09:15 -0700 Subject: [PATCH 063/329] Add memoize method to Cache and Index data types --- diskcache/core.py | 5 +++++ diskcache/memo.py | 8 ++++---- diskcache/persistent.py | 2 ++ tests/test_core.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_index.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 3019482..48fdd5d 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -37,6 +37,8 @@ INT_TYPES = (int,) io_open = open # pylint: disable=invalid-name +from .memo import memoize + try: WindowsError except NameError: @@ -1469,6 +1471,9 @@ def pull(self, prefix=None, default=(None, None), side='front', return key, value + memoize = memoize + + def check(self, fix=False, retry=False): """Check database and file system consistency. diff --git a/diskcache/memo.py b/diskcache/memo.py index 3a2243a..d8f81ee 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -4,7 +4,7 @@ from functools import wraps -from .core import ENOVAL +MARK = object() def memoize(cache, name=None, typed=False, expire=None, tag=None): """Memoizing cache decorator. @@ -80,7 +80,7 @@ def wrapper(*args, **kwargs): key = reference + args if kwargs: - key += (ENOVAL,) + key += (MARK,) sorted_items = sorted(kwargs.items()) for item in sorted_items: @@ -92,9 +92,9 @@ def wrapper(*args, **kwargs): if kwargs: key += tuple(type(value) for _, value in sorted_items) - result = cache.get(key, default=ENOVAL, retry=True) + result = cache.get(key, default=MARK, retry=True) - if result is ENOVAL: + if result is MARK: result = function(*args, **kwargs) cache.set(key, result, expire=expire, tag=tag, retry=True) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index a8d7485..3d8f89e 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -634,6 +634,8 @@ def __init__(self, *args, **kwargs): directory = mkdtemp(prefix='diskcache-') self._cache = Cache(directory, eviction_policy='none') self.update(*args, **kwargs) + self.memoize = self._cache.memoize + self.stats = self._cache.stats @classmethod diff --git a/tests/test_core.py b/tests/test_core.py index 76b5568..f096485 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1307,6 +1307,42 @@ def test_lru_incr(cache): assert cache[0] == 0 +def test_memoize(cache): + count = 1000 + + def fibiter(num): + alpha, beta = 0, 1 + + for _ in range(num): + alpha, beta = beta, alpha + beta + + return alpha + + @cache.memoize() + def fibrec(num): + if num == 0: + return 0 + elif num == 1: + return 1 + else: + return fibrec(num - 1) + fibrec(num - 2) + + cache.stats(enable=True) + + for value in range(count): + assert fibrec(value) == fibiter(value) + + hits1, misses1 = cache.stats() + + for value in range(count): + assert fibrec(value) == fibiter(value) + + hits2, misses2 = cache.stats() + + assert hits2 == (hits1 + count) + assert misses2 == misses1 + + if __name__ == '__main__': import nose nose.runmodule() diff --git a/tests/test_index.py b/tests/test_index.py index 38fb9d2..5bcbfdc 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -146,3 +146,39 @@ def test_state(index): state = pickle.dumps(index) values = pickle.loads(state) assert values == mapping + + +def test_memoize(index): + count = 1000 + + def fibiter(num): + alpha, beta = 0, 1 + + for _ in range(num): + alpha, beta = beta, alpha + beta + + return alpha + + @index.memoize() + def fibrec(num): + if num == 0: + return 0 + elif num == 1: + return 1 + else: + return fibrec(num - 1) + fibrec(num - 2) + + index.stats(enable=True) + + for value in range(count): + assert fibrec(value) == fibiter(value) + + hits1, misses1 = index.stats() + + for value in range(count): + assert fibrec(value) == fibiter(value) + + hits2, misses2 = index.stats() + + assert hits2 == (hits1 + count) + assert misses2 == misses1 From a8c9259377072cf0ac15d7c7b72e57ce58cbb464 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 09:36:25 -0700 Subject: [PATCH 064/329] Move memoize to DjangoCache type --- diskcache/djangocache.py | 85 +++++++++++++++++++++++++++++++++++++++- diskcache/memo.py | 1 + 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index e15c576..108247a 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -1,5 +1,6 @@ "Django-compatible disk and file backed cache." +from functools import wraps from django.core.cache.backends.base import BaseCache try: @@ -10,6 +11,8 @@ from .fanout import FanoutCache +MARK = object() + class DjangoCache(BaseCache): "Django-compatible disk and file backed cache." @@ -26,7 +29,6 @@ def __init__(self, directory, params): options = params.get('OPTIONS', {}) self._directory = directory self._cache = FanoutCache(directory, shards, timeout, **options) - self.memoize = self._cache.memoize @property @@ -327,3 +329,84 @@ def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT): # ticket 21147 - avoid time.time() related precision issues timeout = -1 return None if timeout is None else timeout + + + def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, + typed=False, tag=None): + """Memoizing cache decorator. + + Decorator to wrap callable with memoizing function using cache. + Repeated calls with the same arguments will lookup result in cache and + avoid function evaluation. + + If name is set to None (default), the callable name will be determined + automatically. + + If typed is set to True, function arguments of different types will be + cached separately. For example, f(3) and f(3.0) will be treated as + distinct calls with distinct results. + + The original underlying function is accessible through the __wrapped__ + attribute. This is useful for introspection, for bypassing the cache, + or for rewrapping the function with a different cache. + + Remember to call memoize when decorating a callable. If you forget, then a + TypeError will occur. + + :param str name: name given for callable (default None, automatic) + :param float timeout: seconds until the item expires + (default 300 seconds) + :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) + :return: callable decorator + + """ + # Caution: Nearly identical code exists in memo.memoize + if callable(name): + raise TypeError('name cannot be callable') + + def decorator(function): + "Decorator created by memoize call for callable." + if name is None: + try: + reference = function.__qualname__ + except AttributeError: + reference = function.__name__ + + reference = function.__module__ + reference + else: + reference = name + + reference = (reference,) + + @wraps(function) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + + key = reference + args + + if kwargs: + key += (MARK,) + sorted_items = sorted(kwargs.items()) + + for item in sorted_items: + key += item + + if typed: + key += tuple(type(arg) for arg in args) + + if kwargs: + key += tuple(type(value) for _, value in sorted_items) + + result = self.get(key, MARK, version, retry=True) + + if result is MARK: + result = function(*args, **kwargs) + self.set(key, result, timeout, version, tag=tag, retry=True) + + return result + + return wrapper + + return decorator diff --git a/diskcache/memo.py b/diskcache/memo.py index d8f81ee..20c6404 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -56,6 +56,7 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): :return: callable decorator """ + # Caution: Nearly identical code exists in DjangoCache.memoize if callable(name): raise TypeError('name cannot be callable') From e2eb35dd09c086a36183d692a3b14575eb6c82b4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 09:36:45 -0700 Subject: [PATCH 065/329] Move memoize to Index type --- diskcache/persistent.py | 52 +++++++++++++++++++++++++++++++++++++++-- tests/test_index.py | 6 ++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 3d8f89e..bf33207 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -634,8 +634,6 @@ def __init__(self, *args, **kwargs): directory = mkdtemp(prefix='diskcache-') self._cache = Cache(directory, eviction_policy='none') self.update(*args, **kwargs) - self.memoize = self._cache.memoize - self.stats = self._cache.stats @classmethod @@ -1233,6 +1231,56 @@ def __ne__(self, other): return not self == other + def memoize(self, name=None, typed=False): + """Memoizing cache decorator. + + Decorator to wrap callable with memoizing function using cache. + Repeated calls with the same arguments will lookup result in cache and + avoid function evaluation. + + If name is set to None (default), the callable name will be determined + automatically. + + If typed is set to True, function arguments of different types will be + cached separately. For example, f(3) and f(3.0) will be treated as + distinct calls with distinct results. + + The original underlying function is accessible through the __wrapped__ + attribute. This is useful for introspection, for bypassing the cache, + or for rewrapping the function with a different cache. + + >>> from diskcache import Index + >>> mapping = Index('/tmp/diskcache/index') + >>> @mapping.memoize(typed=True) + ... def fibonacci(number): + ... if number == 0: + ... return 0 + ... elif number == 1: + ... return 1 + ... else: + ... return fibonacci(number - 1) + fibonacci(number - 2) + >>> print(sum(fibonacci(number=value) for value in range(100))) + 573147844013817084100 + + Remember to call memoize when decorating a callable. If you forget, + then a TypeError will occur. Note the lack of parenthenses after + memoize below: + + >>> @mapping.memoize + ... def test(): + ... pass + Traceback (most recent call last): + ... + TypeError: name cannot be callable + + :param str name: name given for callable (default None, automatic) + :param bool typed: cache different types separately (default False) + :return: callable decorator + + """ + return self._cache.memoize(name, typed) + + def __repr__(self): """index.__repr__() <==> repr(index) diff --git a/tests/test_index.py b/tests/test_index.py index 5bcbfdc..2b4aa14 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -168,17 +168,17 @@ def fibrec(num): else: return fibrec(num - 1) + fibrec(num - 2) - index.stats(enable=True) + index._cache.stats(enable=True) for value in range(count): assert fibrec(value) == fibiter(value) - hits1, misses1 = index.stats() + hits1, misses1 = index._cache.stats() for value in range(count): assert fibrec(value) == fibiter(value) - hits2, misses2 = index.stats() + hits2, misses2 = index._cache.stats() assert hits2 == (hits1 + count) assert misses2 == misses1 From 661de4afa7cf327899db7ba1d1b328ce3a9279b4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 10:16:15 -0700 Subject: [PATCH 066/329] Add transact method to Deque and Index and improve docs --- diskcache/core.py | 30 +++++++++++++++--- diskcache/persistent.py | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 48fdd5d..c5c683a 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -609,9 +609,31 @@ def _execute_with_retry(statement, *args, **kwargs): @cl.contextmanager def transact(self, retry=False): - """Lock the cache to perform a transaction. + """Context manager to perform a transaction by locking the cache. - # TODO + While the cache is locked, no other write operation is permitted. + Transactions should therefore be as short as possible. Read and write + operations performed in a transaction are atomic. Read operations may + occur concurrent to a transaction. + + Transactions may be nested and may not be shared between threads. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + >>> cache = Cache('/tmp/diskcache') + >>> _ = cache.clear() + >>> with cache.transact(): # Atomically increment two keys. + ... _ = cache.incr('total', 123.4) + ... _ = cache.incr('count', 1) + >>> with cache.transact(): # Atomically calculate average. + ... average = cache['total'] / cache['count'] + >>> average + 123.4 + + :param bool retry: retry if database timeout occurs (default False) + :return: context manager for use in `with` statement + :raises Timeout: if database timeout occurs """ with self._transact(retry=retry): @@ -1285,7 +1307,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, See also `Cache.pull`. - >>> cache = Cache('/tmp/test') + >>> cache = Cache('/tmp/diskcache') >>> _ = cache.clear() >>> print(cache.push('first value')) 500000000000000 @@ -1379,7 +1401,7 @@ def pull(self, prefix=None, default=(None, None), side='front', See also `Cache.push` and `Cache.get`. - >>> cache = Cache('/tmp/test') + >>> cache = Cache('/tmp/diskcache') >>> _ = cache.clear() >>> cache.pull() (None, None) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index bf33207..77f44d0 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -6,6 +6,7 @@ import sys from collections import OrderedDict +from contextlib import contextmanager from itertools import islice from shutil import rmtree from tempfile import mkdtemp @@ -583,6 +584,39 @@ def rotate(self, steps=1): __hash__ = None + @contextmanager + def transact(self): + """Context manager to perform a transaction by locking the deque. + + While the deque is locked, no other write operation is permitted. + Transactions should therefore be as short as possible. Read and write + operations performed in a transaction are atomic. Read operations may + occur concurrent to a transaction. + + Transactions may be nested and may not be shared between threads. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + >>> from diskcache import Deque + >>> deque = Deque(directory='/tmp/diskcache/deque') + >>> deque.clear() + >>> deque += range(5) + >>> with deque.transact(): # Atomically rotate elements. + ... value = deque.pop() + ... deque.appendleft(value) + >>> list(deque) + [4, 0, 1, 2, 3] + + :param bool retry: retry if database timeout occurs (default False) + :return: context manager for use in `with` statement + :raises Timeout: if database timeout occurs + + """ + with self._cache.transact(retry=True): + yield + + class Index(MutableMapping): """Persistent mutable mapping with insertion order iteration. @@ -1281,6 +1315,39 @@ def memoize(self, name=None, typed=False): return self._cache.memoize(name, typed) + @contextmanager + def transact(self): + """Context manager to perform a transaction by locking the index. + + While the index is locked, no other write operation is permitted. + Transactions should therefore be as short as possible. Read and write + operations performed in a transaction are atomic. Read operations may + occur concurrent to a transaction. + + Transactions may be nested and may not be shared between threads. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + >>> from diskcache import Index + >>> mapping = Index('/tmp/diskcache/index') + >>> with mapping.transact(): # Atomically increment two keys. + ... mapping['total'] = mapping.get('total', 0) + 123.4 + ... mapping['count'] = mapping.get('count', 0) + 1 + >>> with mapping.transact(): # Atomically calculate average. + ... average = mapping['total'] / mapping['count'] + >>> average + 123.4 + + :param bool retry: retry if database timeout occurs (default False) + :return: context manager for use in `with` statement + :raises Timeout: if database timeout occurs + + """ + with self._cache.transact(retry=True): + yield + + def __repr__(self): """index.__repr__() <==> repr(index) From 26c2768f8da5a0d436688b6180106c0e039637e7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:21:23 -0700 Subject: [PATCH 067/329] Add cache property to Deque and Index --- diskcache/persistent.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 77f44d0..b015438 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -135,6 +135,12 @@ def fromcache(cls, cache, iterable=()): return self + @property + def cache(self): + "Cache used by deque." + return self._cache + + @property def directory(self): "Directory path where deque is stored." @@ -697,6 +703,12 @@ def fromcache(cls, cache, *args, **kwargs): return self + @property + def cache(self): + "Cache used by index." + return self._cache + + @property def directory(self): "Directory path where items are stored." From 89a6fe4a05c569f1d5fea88b5845fc4eef6a718e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:22:06 -0700 Subject: [PATCH 068/329] Change get/set/del-item to use faster algorithm. --- diskcache/persistent.py | 69 +++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index b015438..2cd7b06 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -147,30 +147,36 @@ def directory(self): return self._cache.directory - def _key(self, index): + def _index(self, index, func): len_self = len(self) - if index < 0: - index += len_self - if index < 0: + if index >= 0: + if index >= len_self: raise IndexError('deque index out of range') - elif index >= len_self: - raise IndexError('deque index out of range') - diff = len_self - index - 1 - _cache_iterkeys = self._cache.iterkeys + for key in self._cache.iterkeys(): + if index == 0: + try: + return func(key) + except KeyError: + continue + index -= 1 + else: + if index < -len_self: + raise IndexError('deque index out of range') - try: - if index <= diff: - iter_keys = _cache_iterkeys() - key = next(islice(iter_keys, index, index + 1)) - else: - iter_keys = _cache_iterkeys(reverse=True) - key = next(islice(iter_keys, diff, diff + 1)) - except StopIteration: - raise IndexError('deque index out of range') + index += 1 + + for key in self._cache.iterkeys(reverse=True): + if index == 0: + try: + return func(key) + except KeyError: + continue + index += 1 + + raise IndexError('deque index out of range') - return key def __getitem__(self, index): @@ -193,15 +199,7 @@ def __getitem__(self, index): :raises IndexError: if index out of range """ - _key = self._key - _cache = self._cache - - while True: - try: - key = _key(index) - return _cache[key] - except KeyError: - continue + return self._index(index, self._cache.__getitem__) def __setitem__(self, index, value): @@ -223,10 +221,8 @@ def __setitem__(self, index, value): :raises IndexError: if index out of range """ - _key = self._key - _cache = self._cache - key = _key(index) - _cache[key] = value + set_value = lambda key: self._cache.__setitem__(key, value) + self._index(index, set_value) def __delitem__(self, index): @@ -247,16 +243,7 @@ def __delitem__(self, index): :raises IndexError: if index out of range """ - _key = self._key - _cache = self._cache - - while True: - try: - key = _key(index) - del _cache[key] - return - except KeyError: - continue + self._index(index, self._cache.__delitem__) def __repr__(self): From a81c393b2a85e9dd99282ca4884fc4a0da81ffc8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:22:52 -0700 Subject: [PATCH 069/329] Remove test_indexerror_islice --- tests/test_deque.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/test_deque.py b/tests/test_deque.py index 59d9819..cdb97ed 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -113,16 +113,6 @@ def test_indexerror(deque): deque[0] -def test_indexerror_islice(deque): - islice = mock.Mock(side_effect=StopIteration) - - deque.append(0) - - with mock.patch('diskcache.persistent.islice', islice): - with pytest.raises(IndexError): - deque[0] - - def test_repr(): directory = '/tmp/diskcache/deque' deque = dc.Deque(directory=directory) From 45aaad0facb56d3882b8a952699bf2db79394dd0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:23:34 -0700 Subject: [PATCH 070/329] Add items from iterable in transaction during initialization. --- diskcache/persistent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 2cd7b06..769337f 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -106,7 +106,8 @@ def __init__(self, iterable=(), directory=None): if directory is None: directory = mkdtemp() self._cache = Cache(directory, eviction_policy='none') - self.extend(iterable) + with self.transact(): + self.extend(iterable) @classmethod From 78692a8cc879d370ac7e4ac31b152018b4270442 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:23:56 -0700 Subject: [PATCH 071/329] Compare value to item (rather than item to value) --- diskcache/persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 769337f..b1a4c35 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -388,7 +388,7 @@ def count(self, value): :param value: value to count in deque """ - return sum(1 for item in self if item == value) + return sum(1 for item in self if value == item) def extend(self, iterable): From 2446f4dd1f8927590ac9ab410a2e49066764dd09 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:24:18 -0700 Subject: [PATCH 072/329] Simplify Deque.reverse and add comment about Cache.swap idea --- diskcache/persistent.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index b1a4c35..2b2394b 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -509,20 +509,25 @@ def remove(self, value): def reverse(self): """Reverse deque in place. + >>> deque = Deque(directory='/tmp/diskcache/deque') + >>> deque.clear() + >>> deque += 'abc' + >>> deque.reverse() + >>> list(deque) + ['c', 'b', 'a'] + """ - # pylint: disable=protected-access - directory = mkdtemp() - temp = None - - try: - temp = Deque(iterable=reversed(self), directory=directory) - self.clear() - self.extend(temp) - finally: - if temp is not None: - temp._cache.close() - del temp - rmtree(directory) + # GrantJ 2019-03-22 Consider using an algorithm that swaps the values + # at two keys. Like self._cache.swap(key1, key2, retry=True) The swap + # method would exchange the values at two given keys. Then, using a + # forward iterator and a reverse iterator, the reversis method could + # avoid making copies of the values. + temp = Deque(iterable=reversed(self)) + self.clear() + self.extend(temp) + directory = temp.directory + del temp + rmtree(directory) def rotate(self, steps=1): From e1171b259f9cd3203dd1b38183c9650421844f9d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 14:25:00 -0700 Subject: [PATCH 073/329] Remove docstring comments about timeout and retry --- diskcache/persistent.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 2b2394b..046b463 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -594,9 +594,6 @@ def transact(self): Transactions may be nested and may not be shared between threads. - Raises :exc:`Timeout` error when database timeout occurs and `retry` is - `False` (default). - >>> from diskcache import Deque >>> deque = Deque(directory='/tmp/diskcache/deque') >>> deque.clear() @@ -607,7 +604,6 @@ def transact(self): >>> list(deque) [4, 0, 1, 2, 3] - :param bool retry: retry if database timeout occurs (default False) :return: context manager for use in `with` statement :raises Timeout: if database timeout occurs @@ -1331,9 +1327,6 @@ def transact(self): Transactions may be nested and may not be shared between threads. - Raises :exc:`Timeout` error when database timeout occurs and `retry` is - `False` (default). - >>> from diskcache import Index >>> mapping = Index('/tmp/diskcache/index') >>> with mapping.transact(): # Atomically increment two keys. @@ -1344,7 +1337,6 @@ def transact(self): >>> average 123.4 - :param bool retry: retry if database timeout occurs (default False) :return: context manager for use in `with` statement :raises Timeout: if database timeout occurs From c17698806708913313973f834e426d3ba9633bd0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 16:42:30 -0700 Subject: [PATCH 074/329] Add Cache.peek, Deque.peek and Deque.peekleft --- diskcache/core.py | 107 ++++++++++++++++++++++++++++++++++++++++ diskcache/persistent.py | 67 ++++++++++++++++++++++--- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index c5c683a..79c6740 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1493,6 +1493,113 @@ def pull(self, prefix=None, default=(None, None), side='front', return key, value + def peek(self, prefix=None, default=(None, None), side='front', + expire_time=False, tag=False, retry=False): + """Peek at key and value item pair from `side` of queue in cache. + + When prefix is None, integer keys are used. Otherwise, string keys are + used in the format "prefix-integer". Integer starts at 500 trillion. + + If queue is empty, return default. + + Defaults to peeking at key and value item pairs from front of queue. + Set side to 'back' to pull from back of queue. Side must be one of + 'front' or 'back'. + + Expired items are deleted from cache. Operation is atomic. Concurrent + operations will be serialized. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + See also `Cache.pull` and `Cache.push`. + + >>> cache = Cache('/tmp/diskcache') + >>> _ = cache.clear() + >>> for letter in 'abc': + ... print(cache.push(letter)) + 500000000000000 + 500000000000001 + 500000000000002 + >>> key, value = cache.peek() + >>> print(key) + 500000000000000 + >>> value + 'a' + >>> key, value = cache.peek(side='back') + >>> print(key) + 500000000000002 + >>> value + 'c' + + :param str prefix: key prefix (default None, key is integer) + :param default: value to return if key is missing + (default (None, None)) + :param str side: either 'front' or 'back' (default 'front') + :param bool expire_time: if True, return expire_time in tuple + (default False) + :param bool tag: if True, return tag in tuple (default False) + :param bool retry: retry if database timeout occurs (default False) + :return: key and value item pair or default if queue is empty + :raises Timeout: if database timeout occurs + + """ + if prefix is None: + min_key = 0 + max_key = 999999999999999 + else: + min_key = prefix + '-000000000000000' + max_key = prefix + '-999999999999999' + + order = {'front': 'ASC', 'back': 'DESC'} + select = ( + 'SELECT rowid, key, expire_time, tag, mode, filename, value' + ' FROM Cache WHERE ? < key AND key < ? AND raw = 1' + ' ORDER BY key %s LIMIT 1' + ) % order[side] + + if expire_time and tag: + default = default, None, None + elif expire_time or tag: + default = default, None + + while True: + with self._transact(retry) as (sql, cleanup): + rows = sql(select, (min_key, max_key)).fetchall() + + if not rows: + return default + + (rowid, key, db_expire, db_tag, mode, name, db_value), = rows + + if db_expire is not None and db_expire < time.time(): + sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) + cleanup(name) + else: + break + + 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. + return default + else: + raise + finally: + if name is not None: + self._disk.remove(name) + + if expire_time and tag: + return (key, value), db_expire, db_tag + elif expire_time: + return (key, value), db_expire + elif tag: + return (key, value), db_tag + else: + return key, value + + memoize = memoize diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 046b463..e59b1ab 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -185,15 +185,16 @@ def __getitem__(self, index): Return corresponding item for `index` in deque. + See also `Deque.peekleft` and `Deque.peek` for indexing deque at index + ``0`` or ``-1``. + >>> deque = Deque(directory='/tmp/diskcache/deque') >>> deque.clear() >>> deque.extend('abcde') - >>> deque[0] - 'a' - >>> deque[-1] - 'e' - >>> deque[2] - 'c' + >>> deque[1] + 'b' + >>> deque[-2] + 'd' :param int index: index of item :return: corresponding item @@ -417,6 +418,60 @@ def extendleft(self, iterable): self.appendleft(value) + def peek(self): + """Peek at value at back of deque. + + Faster than indexing deque at -1. + + If deque is empty then raise IndexError. + + >>> deque = Deque(directory='/tmp/diskcache/deque') + >>> deque.clear() + >>> deque.peek() + Traceback (most recent call last): + ... + IndexError: peek from an empty deque + >>> deque += 'abc' + >>> deque.peek() + 'c' + + :raises IndexError: if deque is empty + + """ + default = None, ENOVAL + _, value = self._cache.peek(default=default, side='back', retry=True) + if value is ENOVAL: + raise IndexError('peek from an empty deque') + return value + + + def peekleft(self): + """Peek at value at back of deque. + + Faster than indexing deque at 0. + + If deque is empty then raise IndexError. + + >>> deque = Deque(directory='/tmp/diskcache/deque') + >>> deque.clear() + >>> deque.peekleft() + Traceback (most recent call last): + ... + IndexError: peek from an empty deque + >>> deque += 'abc' + >>> deque.peekleft() + 'a' + + :raises IndexError: if deque is empty + + """ + default = None, ENOVAL + _, value = self._cache.peek(default=default, side='front', retry=True) + if value is ENOVAL: + raise IndexError('peek from an empty deque') + return value + + def pop(self): """Remove and return value at back of deque. From 27af7e9f377a9b7fb6e5f9ef1bf9c528aebc3204 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 21:15:04 -0700 Subject: [PATCH 075/329] Add comment about peek and pull similarities --- diskcache/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index 79c6740..b8b46b7 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1436,6 +1436,7 @@ def pull(self, prefix=None, default=(None, None), side='front', :raises Timeout: if database timeout occurs """ + # Caution: Nearly identical code exists in Cache.peek if prefix is None: min_key = 0 max_key = 999999999999999 @@ -1544,6 +1545,7 @@ def peek(self, prefix=None, default=(None, None), side='front', :raises Timeout: if database timeout occurs """ + # Caution: Nearly identical code exists in Cache.pull if prefix is None: min_key = 0 max_key = 999999999999999 From a2b707ed813b9203e66ed15bec5b27493fb83333 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 21:15:43 -0700 Subject: [PATCH 076/329] Add Cache.peekitem --- diskcache/core.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index b8b46b7..20d48dd 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1602,6 +1602,79 @@ def peek(self, prefix=None, default=(None, None), side='front', return key, value + def peekitem(self, last=True, expire_time=False, tag=False, retry=False): + """Peek at key and value item pair in Cache based on iteration order. + + Expired items are deleted from cache. Operation is atomic. Concurrent + operations will be serialized. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + >>> cache = Cache('/tmp/diskcache') + >>> _ = cache.clear() + >>> for index, letter in enumerate('abc'): + ... cache[letter] = index + >>> cache.peekitem() + ('c', 2) + >>> cache.peekitem(last=False) + ('a', 0) + + :param bool last: last item in iteration order (default True) + :param bool expire_time: if True, return expire_time in tuple + (default False) + :param bool tag: if True, return tag in tuple (default False) + :param bool retry: retry if database timeout occurs (default False) + :return: key and value item pair + :raises KeyError: if cache is empty + :raises Timeout: if database timeout occurs + + """ + order = ('ASC', 'DESC') + select = ( + 'SELECT rowid, key, raw, expire_time, tag, mode, filename, value' + ' FROM Cache ORDER BY rowid %s LIMIT 1' + ) % order[last] + + while True: + with self._transact(retry) as (sql, cleanup): + rows = sql(select).fetchall() + + if not rows: + raise KeyError('dictionary is empty') + + (rowid, key, raw, db_expire, db_tag, mode, name, value), = rows + + if db_expire is not None and db_expire < time.time(): + sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) + cleanup(name) + else: + break + + py_key = self._disk.get(key, raw) + + try: + py_value = self._disk.fetch(mode, name, value, False) + except IOError as error: + if error.errno == errno.ENOENT: + # Key was deleted before we could retrieve result. + return default + else: + raise + finally: + if name is not None: + self._disk.remove(name) + + if expire_time and tag: + return (py_key, py_value), db_expire, db_tag + elif expire_time: + return (py_key, py_value), db_expire + elif tag: + return (py_key, py_value), db_tag + else: + return py_key, py_value + + memoize = memoize From 1dfae1c397e4e2f2e6cc2474678c20ee35736aa3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 21:25:41 -0700 Subject: [PATCH 077/329] Fix typo in docstring --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index 20d48dd..0d01f74 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1603,7 +1603,7 @@ def peek(self, prefix=None, default=(None, None), side='front', def peekitem(self, last=True, expire_time=False, tag=False, retry=False): - """Peek at key and value item pair in Cache based on iteration order. + """Peek at key and value item pair in cache based on iteration order. Expired items are deleted from cache. Operation is atomic. Concurrent operations will be serialized. From e1b24a18eed2971eeef7dd57282bff8d64954d2c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 21:26:01 -0700 Subject: [PATCH 078/329] Update doctest for consistency --- diskcache/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 0d01f74..5bd0635 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1613,8 +1613,8 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): >>> cache = Cache('/tmp/diskcache') >>> _ = cache.clear() - >>> for index, letter in enumerate('abc'): - ... cache[letter] = index + >>> for num, letter in enumerate('abc'): + ... cache[letter] = num >>> cache.peekitem() ('c', 2) >>> cache.peekitem(last=False) From f22dba80df105605df6f9585a7f9e29db598a782 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 21:27:04 -0700 Subject: [PATCH 079/329] Add Index.peekitem --- diskcache/persistent.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index e59b1ab..d7cfb89 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -853,6 +853,26 @@ def setdefault(self, key, default=None): _cache.add(key, default, retry=True) + def peekitem(self, last=True): + """Peek at key and value item pair in index based on iteration order. + + >>> index = Index('/tmp/diskcache/index') + >>> index.clear() + >>> for num, letter in enumerate('xyz'): + ... index[letter] = num + >>> index.peekitem() + ('z', 2) + >>> index.peekitem(last=False) + ('x', 0) + + :param bool last: last item in iteration order (default True) + :return: key and value item pair + :raises KeyError: if cache is empty + + """ + return self._cache.peekitem(last, retry=True) + + def pop(self, key, default=ENOVAL): """Remove corresponding item for `key` from index and return value. From 75ee2a44a8f83d53aea62375ccc9344fd44b020b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 22:00:29 -0700 Subject: [PATCH 080/329] Add sphinx doc directives to persistent data types --- diskcache/persistent.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index d7cfb89..d564098 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -271,6 +271,9 @@ def __iadd__(self, iterable): Extend back side of deque with items from iterable. + :param iterable: iterable of items to append to deque + :return: deque with added items + """ self.extend(iterable) return self @@ -387,6 +390,7 @@ def count(self, value): 4 :param value: value to count in deque + :return: count of items equal to value in deque """ return sum(1 for item in self if value == item) @@ -435,6 +439,7 @@ def peek(self): >>> deque.peek() 'c' + :return: value at back of deque :raises IndexError: if deque is empty """ @@ -462,6 +467,7 @@ def peekleft(self): >>> deque.peekleft() 'a' + :return: value at front of deque :raises IndexError: if deque is empty """ @@ -489,6 +495,7 @@ def pop(self): ... IndexError: pop from an empty deque + :return: value at back of deque :raises IndexError: if deque is empty """ @@ -514,6 +521,9 @@ def popleft(self): ... IndexError: pop from an empty deque + :return: value at front of deque + :raises IndexError: if deque is empty + """ default = None, ENOVAL _, value = self._cache.pull(default=default, retry=True) @@ -1303,6 +1313,7 @@ def __eq__(self, other): True :param other: other mapping in equality comparison + :return: True if index equals other """ if len(self) != len(other): @@ -1336,6 +1347,7 @@ def __ne__(self, other): True :param other: other mapping in inequality comparison + :return: True if index does not equal other """ return not self == other From 7c0a54621ff52ee52204a8eff5a7c0d9b19a0324 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Mar 2019 23:11:08 -0700 Subject: [PATCH 081/329] Fixes for pylint --- .pylintrc | 8 ++- diskcache/core.py | 151 +++++++++++++++++++++------------------- diskcache/fanout.py | 2 +- diskcache/persistent.py | 1 - 4 files changed, 86 insertions(+), 76 deletions(-) diff --git a/.pylintrc b/.pylintrc index 1750d67..3ce5d57 100644 --- a/.pylintrc +++ b/.pylintrc @@ -143,7 +143,9 @@ disable=print-statement, no-member, useless-object-inheritance, inconsistent-return-statements, - ungrouped-imports + ungrouped-imports, + not-callable, + duplicate-code # PyCQA/pylint #1055 # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -329,7 +331,7 @@ indent-string=' ' max-line-length=100 # Maximum number of lines in a module. -max-module-lines=2000 +max-module-lines=2500 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. @@ -555,7 +557,7 @@ max-locals=30 max-parents=7 # Maximum number of public methods for a class (see R0904). -max-public-methods=25 +max-public-methods=30 # Maximum number of return / yield for function / method body. max-returns=8 diff --git a/diskcache/core.py b/diskcache/core.py index 5bd0635..2c423f5 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -18,11 +18,13 @@ import warnings import zlib +from .memo import memoize + if sys.hexversion < 0x03000000: import cPickle as pickle # pylint: disable=import-error # ISSUE #25 Fix for http://bugs.python.org/issue10211 from cStringIO import StringIO as BytesIO # pylint: disable=import-error - from thread import get_ident + from thread import get_ident # pylint: disable=import-error TextType = unicode # pylint: disable=invalid-name,undefined-variable BytesType = str INT_TYPES = int, long # pylint: disable=undefined-variable @@ -37,8 +39,6 @@ INT_TYPES = (int,) io_open = open # pylint: disable=invalid-name -from .memo import memoize - try: WindowsError except NameError: @@ -1457,32 +1457,35 @@ def pull(self, prefix=None, default=(None, None), side='front', default = default, None while True: - with self._transact(retry) as (sql, cleanup): - rows = sql(select, (min_key, max_key)).fetchall() + while True: + with self._transact(retry) as (sql, cleanup): + rows = sql(select, (min_key, max_key)).fetchall() - if not rows: - return default + if not rows: + return default - (rowid, key, db_expire, db_tag, mode, name, db_value), = rows + (rowid, key, db_expire, db_tag, mode, name, + db_value), = rows - sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) + sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) - if db_expire is not None and db_expire < time.time(): - cleanup(name) - else: - break + if db_expire is not None and db_expire < time.time(): + cleanup(name) + else: + break - 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. - return default - else: - raise - finally: - if name is not None: - self._disk.remove(name) + 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 + else: + raise + finally: + if name is not None: + self._disk.remove(name) + break if expire_time and tag: return (key, value), db_expire, db_tag @@ -1566,31 +1569,34 @@ def peek(self, prefix=None, default=(None, None), side='front', default = default, None while True: - with self._transact(retry) as (sql, cleanup): - rows = sql(select, (min_key, max_key)).fetchall() + while True: + with self._transact(retry) as (sql, cleanup): + rows = sql(select, (min_key, max_key)).fetchall() - if not rows: - return default + if not rows: + return default - (rowid, key, db_expire, db_tag, mode, name, db_value), = rows + (rowid, key, db_expire, db_tag, mode, name, + db_value), = rows - if db_expire is not None and db_expire < time.time(): - sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) - cleanup(name) - else: - break + if db_expire is not None and db_expire < time.time(): + sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) + cleanup(name) + else: + break - 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. - return default - else: - raise - finally: - if name is not None: - self._disk.remove(name) + 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 + else: + raise + finally: + if name is not None: + self._disk.remove(name) + break if expire_time and tag: return (key, value), db_expire, db_tag @@ -1637,42 +1643,45 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): ) % order[last] while True: - with self._transact(retry) as (sql, cleanup): - rows = sql(select).fetchall() + while True: + with self._transact(retry) as (sql, cleanup): + rows = sql(select).fetchall() - if not rows: - raise KeyError('dictionary is empty') + if not rows: + raise KeyError('dictionary is empty') - (rowid, key, raw, db_expire, db_tag, mode, name, value), = rows + (rowid, db_key, raw, db_expire, db_tag, mode, name, + db_value), = rows - if db_expire is not None and db_expire < time.time(): - sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) - cleanup(name) - else: - break + if db_expire is not None and db_expire < time.time(): + sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) + cleanup(name) + else: + break - py_key = self._disk.get(key, raw) + key = self._disk.get(db_key, raw) - try: - py_value = self._disk.fetch(mode, name, value, False) - except IOError as error: - if error.errno == errno.ENOENT: - # Key was deleted before we could retrieve result. - return default - else: - raise - finally: - if name is not None: - self._disk.remove(name) + 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 + else: + raise + finally: + if name is not None: + self._disk.remove(name) + break if expire_time and tag: - return (py_key, py_value), db_expire, db_tag + return (key, value), db_expire, db_tag elif expire_time: - return (py_key, py_value), db_expire + return (key, value), db_expire elif tag: - return (py_key, py_value), db_tag + return (key, value), db_tag else: - return py_key, py_value + return key, value memoize = memoize diff --git a/diskcache/fanout.py b/diskcache/fanout.py index c3bb14c..886e27f 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -9,7 +9,7 @@ try: from functools import reduce except ImportError: - reduce + reduce # pylint: disable=pointless-statement from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout from .memo import memoize diff --git a/diskcache/persistent.py b/diskcache/persistent.py index d564098..a8bbffe 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -7,7 +7,6 @@ from collections import OrderedDict from contextlib import contextmanager -from itertools import islice from shutil import rmtree from tempfile import mkdtemp From ac07b05e7e70ba95a357abe560296afd28a7799c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 26 Mar 2019 20:20:55 -0700 Subject: [PATCH 082/329] Disable no-name-in-module for pylint --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index 2c423f5..660bc9d 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -24,7 +24,7 @@ import cPickle as pickle # pylint: disable=import-error # ISSUE #25 Fix for http://bugs.python.org/issue10211 from cStringIO import StringIO as BytesIO # pylint: disable=import-error - from thread import get_ident # pylint: disable=import-error + from thread import get_ident # pylint: disable=import-error,no-name-in-module TextType = unicode # pylint: disable=invalid-name,undefined-variable BytesType = str INT_TYPES = int, long # pylint: disable=undefined-variable From 987184f48bf3cee0fe29d7698a80eaabaf1a61f3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 26 Mar 2019 21:12:33 -0700 Subject: [PATCH 083/329] Use the full 80 characters --- diskcache/fanout.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 886e27f..e588766 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -265,8 +265,7 @@ def __contains__(self, key): return key in shard - def pop(self, key, default=None, expire_time=False, tag=False, - retry=False): + def pop(self, key, default=None, expire_time=False, tag=False, retry=False): """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. From 2b22f38de9c8a74b2bd293baa4ce381c64651513 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 26 Mar 2019 21:15:11 -0700 Subject: [PATCH 084/329] Change iterators to generator, not list --- diskcache/fanout.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index e588766..97bbc38 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -493,14 +493,14 @@ def __setstate__(self, state): def __iter__(self): "Iterate keys in cache including expired items." - iterators = [iter(shard) for shard in self._shards] + 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." - iterators = [reversed(shard) for shard in self._shards] - return it.chain.from_iterable(reversed(iterators)) + iterators = (reversed(shard) for shard in reversed(self._shards)) + return it.chain.from_iterable(iterators) def __len__(self): From 7c5d398e26f4c84e752da17bc67a1b35efe86ba4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 26 Mar 2019 21:15:44 -0700 Subject: [PATCH 085/329] Add delay before expire --- docs/tutorial.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 52a95a6..8b05c0d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -201,6 +201,8 @@ Another four methods remove items from the cache. 10 >>> list(cache) [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + >>> import time + >>> time.sleep(1) >>> cache.expire() 10 From 6f7db0a33f7c08c319f0126eadf8d00f9156b895 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 26 Mar 2019 22:05:27 -0700 Subject: [PATCH 086/329] Remove cache.check() from doctests --- docs/tutorial.rst | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8b05c0d..c168cf7 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -308,13 +308,8 @@ incur an extra overhead on cache lookups. Increment and decrement operations are not counted in cache statistics. The third is :meth:`check ` which verifies cache -consistency. It can also fix inconsistencies and reclaim unused space. - - >>> warning, = cache.check(fix=True) - >>> type(warning.message) - - -The return value is a list of warnings. +consistency. It can also fix inconsistencies and reclaim unused space. The +return value is a list of warnings. .. _tutorial-fanoutcache: From 9820c701b4addd67d2d721371e8c82de8e92009b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 27 Mar 2019 09:28:51 -0700 Subject: [PATCH 087/329] Add Cache.touch, FanoutCache.touch, and DjangoCache.touch with tests --- diskcache/core.py | 37 +++++++++++++++++++++++++++++++++++++ diskcache/djangocache.py | 18 ++++++++++++++++++ diskcache/fanout.py | 21 +++++++++++++++++++++ tests/test_core.py | 7 +++++++ tests/test_djangocache.py | 17 +++++++++++++++++ tests/test_fanout.py | 7 +++++++ 6 files changed, 107 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index 660bc9d..70c9f05 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -863,6 +863,43 @@ def _cull(self, now, sql, cleanup, limit=None): cleanup(filename) + def touch(self, key, expire=None, retry=False): + """Touch `key` in cache and update `expire` time. + + Raises :exc:`Timeout` error when database timeout occurs and `retry` is + `False` (default). + + :param key: key for item + :param float expire: seconds until item expires + (default None, no expiry) + :param bool retry: retry if database timeout occurs (default False) + :return: True if key was touched + :raises Timeout: if database timeout occurs + + """ + now = time.time() + db_key, raw = self._disk.put(key) + expire_time = None if expire is None else now + expire + + with self._transact(retry) as (sql, cleanup): + rows = sql( + 'SELECT rowid, expire_time FROM Cache' + ' WHERE key = ? AND raw = ?', + (db_key, raw), + ).fetchall() + + if rows: + (rowid, old_expire_time), = rows + + if old_expire_time is None or old_expire_time > now: + sql('UPDATE Cache SET expire_time = ? WHERE rowid = ?', + (expire_time, rowid), + ) + return True + + return False + + def add(self, key, value, expire=None, read=False, tag=None, retry=False): """Add `key` and `value` item to cache. diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 108247a..e2c223f 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -139,6 +139,24 @@ def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, return self._cache.set(key, value, timeout, read, tag, retry) + def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, retry=True): + """Touch a key in the cache. If timeout is given, that timeout will be + used for the key; otherwise the default cache timeout will be used. + + :param key: key for item + :param float timeout: seconds until the item expires + (default 300 seconds) + :param int version: key version number (default None, cache parameter) + :param bool retry: retry if database timeout occurs (default True) + :return: True if key was touched + + """ + # pylint: disable=arguments-differ + key = self.make_key(key, version=version) + timeout = self.get_backend_timeout(timeout=timeout) + return self._cache.touch(key, timeout, retry) + + def pop(self, key, default=None, version=None, expire_time=False, tag=False, retry=True): """Remove corresponding item for `key` from cache and return value. diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 97bbc38..7691c8b 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -99,6 +99,27 @@ def __setitem__(self, key, value): shard[key] = value + def touch(self, key, expire=None, retry=False): + """Touch `key` in cache and update `expire` time. + + If database timeout occurs then fails silently unless `retry` is set to + `True` (default `False`). + + :param key: key for item + :param float expire: seconds until the key expires + (default None, no expiry) + :param bool retry: retry if database timeout occurs (default False) + :return: True if key was touched + + """ + index = self._hash(key) % self._count + shard = self._shards[index] + try: + return shard.touch(key, expire, retry) + except Timeout: + return False + + def add(self, key, value, expire=None, read=False, tag=None, retry=False): """Add `key` and `value` item to cache. diff --git a/tests/test_core.py b/tests/test_core.py index f096485..422a065 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -785,6 +785,13 @@ def test_contains(cache): assert 0 in cache +def test_touch(cache): + assert cache.set(0, None, expire=60) + assert cache.touch(0, expire=None) + assert cache.touch(0, expire=0) + assert not cache.touch(0) + + def test_add(cache): assert cache.add(1, 1) assert cache.get(1) == 1 diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index b8e554f..113156c 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -286,6 +286,23 @@ def test_cache_read_for_model_instance_with_deferred(self): # We only want the default expensive calculation run on creation and set self.assertEqual(expensive_calculation.num_runs, runs_before_cache_read) + def test_touch(self): + # cache.touch() updates the timeout. + cache.set('expire1', 'very quickly', timeout=1) + self.assertTrue(cache.touch('expire1', timeout=2)) + time.sleep(1) + self.assertTrue(cache.has_key('expire1')) + time.sleep(2) + self.assertFalse(cache.has_key('expire1')) + + # cache.touch() works without the timeout argument. + cache.set('expire1', 'very quickly', timeout=1) + self.assertTrue(cache.touch('expire1')) + time.sleep(2) + self.assertTrue(cache.has_key('expire1')) + + self.assertFalse(cache.touch('nonexistent')) + def test_expiration(self): # Cache values can be set to expire cache.set('expire1', 'very quickly', 1) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 77f7384..65cb1fb 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -110,6 +110,13 @@ def test_set_timeout(cache): assert not cache.set(0, 0) +def test_touch(cache): + assert cache.set(0, None, expire=60) + assert cache.touch(0, expire=None) + assert cache.touch(0, expire=0) + assert not cache.touch(0) + + def test_add(cache): assert cache.add(0, 0) assert not cache.add(0, 1) From 85be791009bdb0ed730fba5fe8822d495c4f2ba7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 27 Mar 2019 11:19:20 -0700 Subject: [PATCH 088/329] Provide access to FanoutCache used by DjangoCache --- diskcache/djangocache.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index e2c223f..4c75d47 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -31,6 +31,12 @@ def __init__(self, directory, params): self._cache = FanoutCache(directory, shards, timeout, **options) + @property + def cache(self): + "FanoutCache used by DjangoCache." + return self._cache + + @property def directory(self): """Cache directory.""" From 2649ac9054e5761a487e8488640c091ce14b5bb2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 27 Mar 2019 13:44:24 -0700 Subject: [PATCH 089/329] Pylint fixes --- diskcache/core.py | 2 +- diskcache/recipes.py | 181 ++++++++++++++++++++++++++++++++++++++++++ tests/test_doctest.py | 9 +++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 diskcache/recipes.py diff --git a/diskcache/core.py b/diskcache/core.py index 70c9f05..7c7a7a7 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -881,7 +881,7 @@ def touch(self, key, expire=None, retry=False): db_key, raw = self._disk.put(key) expire_time = None if expire is None else now + expire - with self._transact(retry) as (sql, cleanup): + with self._transact(retry) as (sql, _): rows = sql( 'SELECT rowid, expire_time FROM Cache' ' WHERE key = ? AND raw = ?', diff --git a/diskcache/recipes.py b/diskcache/recipes.py new file mode 100644 index 0000000..939786c --- /dev/null +++ b/diskcache/recipes.py @@ -0,0 +1,181 @@ +"""Cache Recipes + + +""" + +import os +import threading +import time + + +class Averager(object): + """Recipe for calculating a running average. + + Sometimes known as "online statistics," the running average maintains the + total and count. The average can then be calculated at any time. + + >>> import diskcache + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> ave = Averager(cache, 'latency') + >>> ave.add(0.080) + >>> ave.add(0.120) + >>> ave.get() + 0.1 + >>> ave.add(0.160) + >>> ave.get() + 0.12 + + """ + def __init__(self, cache, key): + self._cache = cache + self._key = key + + def add(self, value): + "Add `value` to average." + with self._cache.transact(): + total, count = self._cache.get(self._key, default=(0.0, 0)) + total += value + count += 1 + self._cache.set(self._key, (total, count)) + + def get(self): + "Get current average." + total, count = self._cache.get(self._key, default=(0.0, 0), retry=True) + return 0.0 if count == 0 else total / count + + def pop(self): + "Return current average and reset average to 0.0." + total, count = self._cache.pop(self._key, default=(0.0, 0), retry=True) + return 0.0 if count == 0 else total / count + + +class Lock(object): + """Recipe for cross-process and cross-thread lock. + + >>> import diskcache + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> lock = Lock(cache, 'report-123') + >>> lock.acquire() + >>> lock.release() + >>> with lock: + ... pass + + """ + def __init__(self, cache, key): + self._cache = cache + self._key = key + + def acquire(self): + "Acquire lock using spin-lock algorithm." + while True: + if self._cache.add(self._key, None, retry=True): + return + time.sleep(0.001) + + def release(self): + "Release lock by deleting key." + self._cache.delete(self._key, retry=True) + + def __enter__(self): + self.acquire() + + def __exit__(self, *exc_info): + self.release() + + +class RLock(object): + """Recipe for cross-process and cross-thread re-entrant lock. + + >>> import diskcache + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> rlock = RLock(cache, 'user-123') + >>> rlock.acquire() + >>> rlock.acquire() + >>> rlock.release() + >>> rlock.release() + >>> with rlock: + ... pass + >>> rlock.release() + Traceback (most recent call last): + ... + AssertionError: cannot release un-acquired lock + + """ + def __init__(self, cache, key): + self._cache = cache + self._key = key + pid = os.getpid() + tid = threading.get_ident() + self._value = '{}-{}'.format(pid, tid) + + def acquire(self): + "Acquire lock by incrementing count using spin-lock algorithm." + while True: + with self._cache.transact(): + value, count = self._cache.get(self._key, default=(None, 0)) + if self._value == value or count == 0: + self._cache.set(self._key, (self._value, count + 1)) + return + time.sleep(0.001) + + def release(self): + "Release lock by decrementing count." + with self._cache.transact(): + value, count = self._cache.get(self._key, default=(None, 0)) + is_owned = self._value == value and count > 0 + assert is_owned, 'cannot release un-acquired lock' + self._cache.set(self._key, (value, count - 1)) + + def __enter__(self): + self.acquire() + + def __exit__(self, *exc_info): + self.release() + + +class BoundedSemaphore(object): + """Recipe for cross-process and cross-thread bounded semaphore. + + >>> import diskcache + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> semaphore = BoundedSemaphore(cache, 'max-connections', value=2) + >>> semaphore.acquire() + >>> semaphore.acquire() + >>> semaphore.release() + >>> with semaphore: + ... pass + >>> semaphore.release() + >>> semaphore.release() + Traceback (most recent call last): + ... + AssertionError: cannot release un-acquired semaphore + + """ + def __init__(self, cache, key, value=1): + self._cache = cache + self._key = key + self._value = value + + def acquire(self): + "Acquire semaphore by decrementing value using spin-lock algorithm." + while True: + with self._cache.transact(): + value = self._cache.get(self._key, default=self._value) + if value > 0: + self._cache.set(self._key, value - 1) + return + time.sleep(0.001) + + def release(self): + "Release semaphore by incrementing value." + with self._cache.transact(): + value = self._cache.get(self._key, default=self._value) + assert self._value > value, 'cannot release un-acquired semaphore' + value += 1 + self._cache.set(self._key, value) + + def __enter__(self): + self.acquire() + + def __exit__(self, *exc_info): + self.release() diff --git a/tests/test_doctest.py b/tests/test_doctest.py index b523e46..d90d548 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -7,6 +7,7 @@ import diskcache.fanout import diskcache.memo import diskcache.persistent +import diskcache.recipes def rmdir(directory): @@ -53,3 +54,11 @@ def test_tutorial(): rmdir('/tmp/mydir') failures, _ = doctest.testfile('../docs/tutorial.rst') assert failures == 0 + + +def test_recipes(): + if sys.hexversion < 0x03000000: + return + rmdir('/tmp/diskcache') + failures, _ = doctest.testmod(diskcache.recipes) + assert failures == 0 From 303099dcbe0dce170bce943f744c238b52d520b5 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 28 Mar 2019 17:21:48 -0700 Subject: [PATCH 090/329] Add throttle and barrier decorators to recipes --- diskcache/recipes.py | 103 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 939786c..25445a2 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -3,6 +3,7 @@ """ +import functools import os import threading import time @@ -67,9 +68,7 @@ def __init__(self, cache, key): def acquire(self): "Acquire lock using spin-lock algorithm." - while True: - if self._cache.add(self._key, None, retry=True): - return + while not self._cache.add(self._key, None, retry=True): time.sleep(0.001) def release(self): @@ -92,10 +91,10 @@ class RLock(object): >>> rlock.acquire() >>> rlock.acquire() >>> rlock.release() - >>> rlock.release() >>> with rlock: ... pass >>> rlock.release() + >>> rlock.release() Traceback (most recent call last): ... AssertionError: cannot release un-acquired lock @@ -179,3 +178,99 @@ def __enter__(self): def __exit__(self, *exc_info): self.release() + + +def throttle(cache, count, seconds, name=None, time=time.time, + sleep=time.sleep): + """Decorator to throttle calls to function. + + >>> import diskcache, time + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> @throttle(cache, 1, 1) + ... def int_time(): + ... return int(time.time()) + >>> times = [int_time() for _ in range(4)] + >>> [times[i] - times[i - 1] for i in range(1, 4)] + [1, 1, 1] + + """ + def decorator(func): + rate = count / float(seconds) + + if name is None: + try: + key = func.__qualname__ + except AttributeError: + key = func.__name__ + + key = func.__module__ + '.' + key + else: + key = name + + cache.set(key, (time(), count), retry=True) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + while True: + with cache.transact(): + last, tally = cache.get(key, retry=True) + now = time() + tally += (now - last) * rate + delay = 0 + + if tally > count: + cache.set(key, (now, count - 1), retry=True) + elif tally >= 1: + cache.set(key, (now, tally - 1), retry=True) + else: + delay = (1 - tally) / rate + + if delay: + sleep(delay) + else: + break + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def barrier(cache, lock_factory, name=None): + """Barrier to calling decorated function. + + >>> import diskcache, time + >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> @barrier(cache, Lock) + ... def work(num): + ... time.sleep(1) + ... return int(time.time()) + >>> from concurrent.futures import ThreadPoolExecutor + >>> with ThreadPoolExecutor() as executor: + ... times = sorted(executor.map(work, range(4))) + >>> [times[i] - times[i - 1] for i in range(1, 4)] + [1, 1, 1] + + """ + def decorator(func): + if name is None: + try: + key = func.__qualname__ + except AttributeError: + key = func.__name__ + + key = func.__module__ + '.' + key + else: + key = name + + lock = lock_factory(cache, key) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + with lock: + return func(*args, **kwargs) + + return wrapper + + return decorator From 2e91dc6e1d1dbdc0985ece3241df1889bb788954 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 08:57:12 -0700 Subject: [PATCH 091/329] Add doctest for Disk Cache recipes: stampede-lock decorator recipe --- diskcache/recipes.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 25445a2..f3f01fe 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -1,5 +1,22 @@ -"""Cache Recipes - +"""Disk Cache Recipes + +>>> import diskcache as dc, time +>>> cache = dc.Cache('/tmp/diskcache/example') +>>> @dc.memoize(cache) +... @dc.barrier(cache, dc.Lock) +... @dc.memoize(cache) +... def work(num): +... time.sleep(1) +... return -num +>>> from concurrent.futures import ThreadPoolExecutor +>>> with ThreadPoolExecutor() as executor: +... start = time.time() +... times = list(executor.map(work, range(5))) +... end = time.time() +>>> times +[0, -1, -2, -3, -4] +>>> int(end - start) +5 """ From 1277747b0c5ad80d5132b08f4ce2987932cd4ce6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 09:01:46 -0700 Subject: [PATCH 092/329] Add recipes to top-level package exports --- diskcache/__init__.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 26532cc..17cbeb3 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -3,21 +3,30 @@ from .core import Cache, Disk, UnknownFileWarning, EmptyDirWarning, Timeout from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache +from .memo import memoize from .persistent import Deque, Index +from .recipes import Averager, Lock, RLock, BoundedSemaphore, throttle, barrier __all__ = [ + 'Averager', + 'BoundedSemaphore', 'Cache', - 'Disk', - 'UnknownFileWarning', - 'EmptyDirWarning', - 'Timeout', 'DEFAULT_SETTINGS', + 'Deque', + 'Disk', 'ENOVAL', 'EVICTION_POLICY', - 'UNKNOWN', + 'EmptyDirWarning', 'FanoutCache', - 'Deque', 'Index', + 'Lock', + 'RLock', + 'Timeout', + 'UNKNOWN', + 'UnknownFileWarning', + 'barrier', + 'memoize', + 'throttle', ] try: From 15bbb1d4a4de512da760a695db819ab4dd7afe7f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 09:08:06 -0700 Subject: [PATCH 093/329] Improve recipes doctest to show caching --- diskcache/recipes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index f3f01fe..e9f894f 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -17,6 +17,14 @@ [0, -1, -2, -3, -4] >>> int(end - start) 5 +>>> with ThreadPoolExecutor() as executor: +... start = time.time() +... times = list(executor.map(work, range(5))) +... end = time.time() +>>> times +[0, -1, -2, -3, -4] +>>> int(end - start) +0 """ From 8ef124ff6547a6a2d210573d19fb8d18df974ca9 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 09:10:15 -0700 Subject: [PATCH 094/329] Add expire and tag parameters to recipes --- diskcache/recipes.py | 60 +++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index e9f894f..83505df 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -52,9 +52,11 @@ class Averager(object): 0.12 """ - def __init__(self, cache, key): + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key + self._expire = expire + self._tag = tag def add(self, value): "Add `value` to average." @@ -62,7 +64,9 @@ def add(self, value): total, count = self._cache.get(self._key, default=(0.0, 0)) total += value count += 1 - self._cache.set(self._key, (total, count)) + self._cache.set( + self._key, (total, count), expire=self._expire, tag=self._tag, + ) def get(self): "Get current average." @@ -87,13 +91,20 @@ class Lock(object): ... pass """ - def __init__(self, cache, key): + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key + self._expire = expire + self._tag = tag def acquire(self): "Acquire lock using spin-lock algorithm." - while not self._cache.add(self._key, None, retry=True): + while True: + added = self._cache.add( + self._key, None, expire=self._expire, tag=self._tag, retry=True, + ) + if added: + break time.sleep(0.001) def release(self): @@ -125,9 +136,11 @@ class RLock(object): AssertionError: cannot release un-acquired lock """ - def __init__(self, cache, key): + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key + self._expire = expire + self._tag = tag pid = os.getpid() tid = threading.get_ident() self._value = '{}-{}'.format(pid, tid) @@ -138,7 +151,10 @@ def acquire(self): with self._cache.transact(): value, count = self._cache.get(self._key, default=(None, 0)) if self._value == value or count == 0: - self._cache.set(self._key, (self._value, count + 1)) + self._cache.set( + self._key, (self._value, count + 1), + expire=self._expire, tag=self._tag, + ) return time.sleep(0.001) @@ -148,7 +164,10 @@ def release(self): value, count = self._cache.get(self._key, default=(None, 0)) is_owned = self._value == value and count > 0 assert is_owned, 'cannot release un-acquired lock' - self._cache.set(self._key, (value, count - 1)) + self._cache.set( + self._key, (value, count - 1), expire=self._expire, + tag=self._tag, + ) def __enter__(self): self.acquire() @@ -175,10 +194,12 @@ class BoundedSemaphore(object): AssertionError: cannot release un-acquired semaphore """ - def __init__(self, cache, key, value=1): + def __init__(self, cache, key, value=1, expire=None, tag=None): self._cache = cache self._key = key self._value = value + self._expire = expire + self._tag = tag def acquire(self): "Acquire semaphore by decrementing value using spin-lock algorithm." @@ -186,7 +207,10 @@ def acquire(self): with self._cache.transact(): value = self._cache.get(self._key, default=self._value) if value > 0: - self._cache.set(self._key, value - 1) + self._cache.set( + self._key, value - 1, expire=self._expire, + tag=self._tag, + ) return time.sleep(0.001) @@ -196,7 +220,9 @@ def release(self): value = self._cache.get(self._key, default=self._value) assert self._value > value, 'cannot release un-acquired semaphore' value += 1 - self._cache.set(self._key, value) + self._cache.set( + self._key, value, expire=self._expire, tag=self._tag, + ) def __enter__(self): self.acquire() @@ -205,8 +231,8 @@ def __exit__(self, *exc_info): self.release() -def throttle(cache, count, seconds, name=None, time=time.time, - sleep=time.sleep): +def throttle(cache, count, seconds, name=None, expire=None, tag=None, + time=time.time, sleep=time.sleep): """Decorator to throttle calls to function. >>> import diskcache, time @@ -232,7 +258,7 @@ def decorator(func): else: key = name - cache.set(key, (time(), count), retry=True) + cache.set(key, (time(), count), expire=expire, tag=tag, retry=True) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -244,9 +270,9 @@ def wrapper(*args, **kwargs): delay = 0 if tally > count: - cache.set(key, (now, count - 1), retry=True) + cache.set(key, (now, count - 1), expire, retry=True) elif tally >= 1: - cache.set(key, (now, tally - 1), retry=True) + cache.set(key, (now, tally - 1), expire, retry=True) else: delay = (1 - tally) / rate @@ -262,7 +288,7 @@ def wrapper(*args, **kwargs): return decorator -def barrier(cache, lock_factory, name=None): +def barrier(cache, lock_factory, name=None, expire=None, tag=None): """Barrier to calling decorated function. >>> import diskcache, time @@ -289,7 +315,7 @@ def decorator(func): else: key = name - lock = lock_factory(cache, key) + lock = lock_factory(cache, key, expire=expire, tag=tag) @functools.wraps(func) def wrapper(*args, **kwargs): From 1d0ee0f80a4da1ce50e0de6ff8545be232e48c86 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 09:46:11 -0700 Subject: [PATCH 095/329] Refactor memoize to use full_name and args_to_key --- diskcache/djangocache.py | 38 ++++----------------- diskcache/memo.py | 73 +++++++++++++++++++++++----------------- diskcache/recipes.py | 13 ++----- 3 files changed, 52 insertions(+), 72 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 4c75d47..16c2414 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -10,8 +10,7 @@ DEFAULT_TIMEOUT = 300 from .fanout import FanoutCache - -MARK = object() +from .memo import MARK, args_to_key, full_name class DjangoCache(BaseCache): @@ -390,43 +389,18 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, if callable(name): raise TypeError('name cannot be callable') - def decorator(function): + def decorator(func): "Decorator created by memoize call for callable." - if name is None: - try: - reference = function.__qualname__ - except AttributeError: - reference = function.__name__ - - reference = function.__module__ + reference - else: - reference = name - - reference = (reference,) + base = (full_name(func),) if name is None else (name,) - @wraps(function) + @wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." - - key = reference + args - - if kwargs: - key += (MARK,) - sorted_items = sorted(kwargs.items()) - - for item in sorted_items: - key += item - - if typed: - key += tuple(type(arg) for arg in args) - - if kwargs: - key += tuple(type(value) for _, value in sorted_items) - + key = args_to_key(base, args, kwargs, typed) result = self.get(key, MARK, version, retry=True) if result is MARK: - result = function(*args, **kwargs) + result = func(*args, **kwargs) self.set(key, result, timeout, version, tag=tag, retry=True) return result diff --git a/diskcache/memo.py b/diskcache/memo.py index 20c6404..76d1e5a 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -6,6 +6,44 @@ MARK = object() + +def full_name(func): + "Return full name of `func` by adding the module and function name." + try: + name = func.__qualname__ + except AttributeError: + name = func.__name__ + return func.__module__ + '.' + name + + +def args_to_key(base, args, kwargs, typed): + """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 + :return: cache key tuple + + """ + key = base + args + + if kwargs: + key += (MARK,) + sorted_items = sorted(kwargs.items()) + + for item in sorted_items: + key += item + + if typed: + key += tuple(type(arg) for arg in args) + + if kwargs: + key += tuple(type(value) for _, value in sorted_items) + + return key + + def memoize(cache, name=None, typed=False, expire=None, tag=None): """Memoizing cache decorator. @@ -60,43 +98,18 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): if callable(name): raise TypeError('name cannot be callable') - def decorator(function): + def decorator(func): "Decorator created by memoize call for callable." - if name is None: - try: - reference = function.__qualname__ - except AttributeError: - reference = function.__name__ - - reference = function.__module__ + reference - else: - reference = name + base = (full_name(func),) if name is None else (name,) - reference = (reference,) - - @wraps(function) + @wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." - - key = reference + args - - if kwargs: - key += (MARK,) - sorted_items = sorted(kwargs.items()) - - for item in sorted_items: - key += item - - if typed: - key += tuple(type(arg) for arg in args) - - if kwargs: - key += tuple(type(value) for _, value in sorted_items) - + key = args_to_key(base, args, kwargs, typed) result = cache.get(key, default=MARK, retry=True) if result is MARK: - result = function(*args, **kwargs) + result = func(*args, **kwargs) cache.set(key, result, expire=expire, tag=tag, retry=True) return result diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 83505df..1dfa965 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -33,6 +33,8 @@ import threading import time +from .memo import full_name + class Averager(object): """Recipe for calculating a running average. @@ -305,16 +307,7 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): """ def decorator(func): - if name is None: - try: - key = func.__qualname__ - except AttributeError: - key = func.__name__ - - key = func.__module__ + '.' + key - else: - key = name - + key = full_name(func) if name is None else name lock = lock_factory(cache, key, expire=expire, tag=tag) @functools.wraps(func) From bf7196dbc6d0a5da20f7afc0be74b8950dac8e24 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 09:52:45 -0700 Subject: [PATCH 096/329] Fixes and improvements for pylint --- .pylintrc | 3 +-- diskcache/recipes.py | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pylintrc b/.pylintrc index 3ce5d57..3314897 100644 --- a/.pylintrc +++ b/.pylintrc @@ -145,7 +145,6 @@ disable=print-statement, inconsistent-return-statements, ungrouped-imports, not-callable, - duplicate-code # PyCQA/pylint #1055 # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -361,7 +360,7 @@ ignore-docstrings=yes ignore-imports=no # Minimum lines number of a similarity. -min-similarity-lines=4 +min-similarity-lines=9 [BASIC] diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 1dfa965..d729cf5 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -234,7 +234,7 @@ def __exit__(self, *exc_info): def throttle(cache, count, seconds, name=None, expire=None, tag=None, - time=time.time, sleep=time.sleep): + time_func=time.time, sleep_func=time.sleep): """Decorator to throttle calls to function. >>> import diskcache, time @@ -260,14 +260,15 @@ def decorator(func): else: key = name - cache.set(key, (time(), count), expire=expire, tag=tag, retry=True) + now = time_func() + cache.set(key, (now, count), expire=expire, tag=tag, retry=True) @functools.wraps(func) def wrapper(*args, **kwargs): while True: with cache.transact(): last, tally = cache.get(key, retry=True) - now = time() + now = time_func() tally += (now - last) * rate delay = 0 @@ -279,7 +280,7 @@ def wrapper(*args, **kwargs): delay = (1 - tally) / rate if delay: - sleep(delay) + sleep_func(delay) else: break From a902c663837098cfdbebf52d1625a82d93feb41d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 29 Mar 2019 10:34:07 -0700 Subject: [PATCH 097/329] Add sub-cache method to DjangoCache and FanoutCache --- diskcache/djangocache.py | 16 ++++++++++------ diskcache/fanout.py | 33 +++++++++++++++++++++++++++++++++ tests/test_djangocache.py | 5 +++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 16c2414..eb28e18 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -30,18 +30,22 @@ def __init__(self, directory, params): self._cache = FanoutCache(directory, shards, timeout, **options) - @property - def cache(self): - "FanoutCache used by DjangoCache." - return self._cache - - @property def directory(self): """Cache directory.""" return self._directory + def cache(self, name): + """Return Cache with given `name` in subdirectory. + + :param str name: subdirectory name for Cache + :return: Cache with given name + + """ + return self._cache.cache(name) + + def deque(self, name): """Return Deque with given `name` in subdirectory. diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 7691c8b..1e48947 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -44,6 +44,7 @@ def __init__(self, directory, shards=8, timeout=0.010, disk=Disk, for num in range(shards) ) self._hash = self._shards[0].disk.hash + self._caches = {} self._deques = {} self._indexes = {} @@ -492,6 +493,7 @@ def close(self): "Close database connection." for shard in self._shards: shard.close() + self._caches.clear() self._deques.clear() self._indexes.clear() @@ -559,6 +561,37 @@ def reset(self, key, value=ENOVAL): return result + def cache(self, name): + """Return Cache with given `name` in subdirectory. + + >>> fanout_cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> cache = fanout_cache.cache('test') + >>> _ = cache.clear() + >>> cache.set('abc', 123) + True + >>> cache.get('abc') + 123 + >>> len(cache) + 1 + >>> cache.delete('abc') + True + + :param str name: subdirectory name for Cache + :return: Cache with given name + + """ + _caches = self._caches + + try: + return _caches[name] + except KeyError: + parts = name.split('/') + directory = op.join(self._directory, 'cache', *parts) + temp = Cache(directory=directory) + _caches[name] = temp + return temp + + def deque(self, name): """Return Deque with given `name` in subdirectory. diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 113156c..45f94a7 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -974,6 +974,11 @@ def test_pickle(self): for key in letters: self.assertEqual(other.get(key), cache.get(key)) + def test_cache(self): + subcache = cache.cache('test') + directory = os.path.join(cache.directory, 'cache', 'test') + self.assertEqual(subcache.directory, directory) + def test_deque(self): deque = cache.deque('test') directory = os.path.join(cache.directory, 'deque', 'test') From a1619c0ca322f7d0ca8583d1621d0412aa838e50 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Apr 2019 20:24:36 -0700 Subject: [PATCH 098/329] Add tests for coverage --- diskcache/core.py | 3 - diskcache/memo.py | 2 + diskcache/persistent.py | 4 ++ tests/test_core.py | 147 ++++++++++++++++++++++++++++++++++++++ tests/test_djangocache.py | 5 ++ tests/test_fanout.py | 26 +++++++ 6 files changed, 184 insertions(+), 3 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 7c7a7a7..dab3806 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1706,9 +1706,6 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): continue else: raise - finally: - if name is not None: - self._disk.remove(name) break if expire_time and tag: diff --git a/diskcache/memo.py b/diskcache/memo.py index 76d1e5a..9f85738 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -10,6 +10,8 @@ def full_name(func): "Return full name of `func` by adding the module and function name." try: + # The __qualname__ attribute is only available in Python 3.3 and later. + # GrantJ 2019-03-29 Remove after support for Python 2 is dropped. name = func.__qualname__ except AttributeError: name = func.__name__ diff --git a/diskcache/persistent.py b/diskcache/persistent.py index a8bbffe..81b12f9 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -116,6 +116,8 @@ def fromcache(cls, cache, iterable=()): >>> cache = Cache('/tmp/diskcache/index') >>> _ = cache.clear() >>> deque = Deque.fromcache(cache, [5, 6, 7, 8]) + >>> deque.cache is cache + True >>> len(deque) 4 >>> 7 in deque @@ -736,6 +738,8 @@ def fromcache(cls, cache, *args, **kwargs): >>> cache = Cache('/tmp/diskcache/index') >>> _ = cache.clear() >>> index = Index.fromcache(cache, {'a': 1, 'b': 2, 'c': 3}) + >>> index.cache is cache + True >>> len(index) 3 >>> 'b' in index diff --git a/tests/test_core.py b/tests/test_core.py index 422a065..c440e0f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -935,8 +935,10 @@ def test_push_pull_prefix(cache): cache.push(value, prefix='key') for value in range(10): + key, peek_value = cache.peek(prefix='key') key, pull_value = cache.pull(prefix='key') assert key.startswith('key') + assert peek_value == value assert pull_value == value assert len(cache) == 0 @@ -949,6 +951,11 @@ def test_push_pull_extras(cache): assert len(cache) == 0 cache.push('test', expire=10) + (key, value), expire_time = cache.peek(expire_time=True) + assert key == 500000000000000 + assert value == 'test' + assert expire_time > time.time() + assert len(cache) == 1 (key, value), expire_time = cache.pull(expire_time=True) assert key == 500000000000000 assert value == 'test' @@ -956,6 +963,11 @@ def test_push_pull_extras(cache): assert len(cache) == 0 cache.push('test', tag='foo') + (key, value), tag = cache.peek(tag=True) + assert key == 500000000000000 + assert value == 'test' + assert tag == 'foo' + assert len(cache) == 1 (key, value), tag = cache.pull(tag=True) assert key == 500000000000000 assert value == 'test' @@ -963,6 +975,12 @@ def test_push_pull_extras(cache): assert len(cache) == 0 cache.push('test') + (key, value), expire_time, tag = cache.peek(expire_time=True, tag=True) + assert key == 500000000000000 + assert value == 'test' + assert expire_time is None + assert tag is None + assert len(cache) == 1 (key, value), expire_time, tag = cache.pull(expire_time=True, tag=True) assert key == 500000000000000 assert value == 'test' @@ -986,6 +1004,17 @@ def test_push_pull_expire(cache): assert len(cache.check()) == 0 +def test_push_peek_expire(cache): + cache.push(0, expire=0.1) + cache.push(0, expire=0.1) + cache.push(0, expire=0.1) + cache.push(1) + time.sleep(0.2) + assert cache.peek() == (500000000000003, 1) + assert len(cache) == 1 + assert len(cache.check()) == 0 + + def test_push_pull_large_value(cache): value = b'test' * (2 ** 20) cache.push(value) @@ -994,6 +1023,14 @@ def test_push_pull_large_value(cache): assert len(cache.check()) == 0 +def test_push_peek_large_value(cache): + value = b'test' * (2 ** 20) + cache.push(value) + assert cache.peek() == (500000000000000, value) + assert len(cache) == 1 + assert len(cache.check()) == 0 + + def test_pull_ioerror(cache): assert cache.push(0) == 500000000000000 @@ -1012,6 +1049,25 @@ def test_pull_ioerror(cache): assert cache.pull() == (None, None) +def test_peek_ioerror(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.ENOENT + fetch.side_effect = [io_error, 0] + + with mock.patch.object(cache, '_disk', disk): + _, value = cache.peek() + assert value == 0 + + def test_pull_ioerror_eacces(cache): assert cache.push(0) == 500000000000000 @@ -1031,6 +1087,97 @@ def test_pull_ioerror_eacces(cache): 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() + + assert cache.set('a', 0) + assert cache.set('b', 1) + assert cache.set('c', 2, expire=10, tag='foo') + assert cache.set('d', 3, expire=0.1) + assert cache.set('e', 4, expire=0.1) + + time.sleep(0.2) + + (key, value), expire_time, tag = cache.peekitem(expire_time=True, tag=True) + assert key == 'c' + assert value == 2 + assert expire_time > 0 + assert tag == 'foo' + + (key, value), expire_time = cache.peekitem(expire_time=True) + assert key == 'c' + assert value == 2 + assert expire_time > 0 + + (key, value), tag = cache.peekitem(tag=True) + assert key == 'c' + assert value == 2 + assert expire_time > 0 + assert tag == 'foo' + + +def test_peekitem_ioerror(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.ENOENT + fetch.side_effect = [io_error, 2] + + with mock.patch.object(cache, '_disk', disk): + _, value = cache.peekitem() + 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()) == [] diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 45f94a7..ea163bc 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -990,6 +990,11 @@ def test_index(self): self.assertEqual(index.directory, directory) def test_memoize(self): + with self.assertRaises(TypeError): + @cache.memoize # <-- Missing parens! + def test(): + pass + count = 1000 def fibiter(num): diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 65cb1fb..89f3a58 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -117,6 +117,19 @@ def test_touch(cache): assert not cache.touch(0) +def test_touch_timeout(cache): + shards = mock.Mock() + shard = mock.Mock() + touch_func = mock.Mock() + + shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) + shard.touch = touch_func + touch_func.side_effect = dc.Timeout + + with mock.patch.object(cache, '_shards', shards): + assert not cache.touch(0) + + def test_add(cache): assert cache.add(0, 0) assert not cache.add(0, 1) @@ -189,6 +202,19 @@ def test_decr(cache): cache.decr('key', delta=2) == -2 +def test_decr_timeout(cache): + shards = mock.Mock() + shard = mock.Mock() + decr_func = mock.Mock() + + shards.__getitem__ = mock.Mock(side_effect=lambda key: shard) + shard.decr = decr_func + decr_func.side_effect = dc.Timeout + + with mock.patch.object(cache, '_shards', shards): + assert cache.decr('key', 1) is None + + def stress_incr(cache, limit): for _ in range(limit): cache.incr(b'key', retry=True) From cfff0ff316d02c1d34fd814d8cc42fc2122741d6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 3 Apr 2019 10:38:07 -0700 Subject: [PATCH 099/329] Add pytest-xdist and setup for parallel testing --- requirements.txt | 1 + tox.ini | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index c982310..00f826c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ pytest pytest-cov pytest-django pytest-env +pytest-xdist sphinx tox twine diff --git a/tox.ini b/tox.ini index 080f92d..3f8de58 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ setenv = [pytest] addopts= + -n auto --ignore tests/benchmark_core.py --ignore tests/benchmark_djangocache.py --ignore tests/benchmark_glob.py From 645ddfc01b44e15004360997464791df93dbd75d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 3 Apr 2019 10:47:40 -0700 Subject: [PATCH 100/329] Update caches and tests for automatic directory creation --- diskcache/core.py | 26 +++---- diskcache/djangocache.py | 3 +- diskcache/fanout.py | 24 ++++--- diskcache/memo.py | 2 +- diskcache/persistent.py | 143 +++++++++++++-------------------------- diskcache/recipes.py | 14 ++-- docs/tutorial.rst | 36 +++++----- tests/test_core.py | 59 ++++++++-------- tests/test_deque.py | 7 +- tests/test_doctest.py | 15 ---- tests/test_fanout.py | 41 ++++++----- tests/test_index.py | 7 +- 12 files changed, 156 insertions(+), 221 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index dab3806..f86cf66 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -13,6 +13,7 @@ import sqlite3 import struct import sys +import tempfile import threading import time import warnings @@ -359,7 +360,7 @@ class EmptyDirWarning(UserWarning): class Cache(object): "Disk and file backed cache." # pylint: disable=bad-continuation - def __init__(self, directory, timeout=60, disk=Disk, **settings): + def __init__(self, directory=None, timeout=60, disk=Disk, **settings): """Initialize cache instance. :param str directory: cache directory @@ -373,6 +374,11 @@ def __init__(self, directory, timeout=60, disk=Disk, **settings): except (TypeError, AssertionError): raise ValueError('disk must subclass diskcache.Disk') + if directory is None: + directory = tempfile.mkdtemp(prefix='diskcache-') + directory = op.expanduser(directory) + directory = op.expandvars(directory) + self._directory = directory self._timeout = 0 # Manually handle retries during initialization. self._local = threading.local() @@ -621,8 +627,7 @@ def transact(self, retry=False): Raises :exc:`Timeout` error when database timeout occurs and `retry` is `False` (default). - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> with cache.transact(): # Atomically increment two keys. ... _ = cache.incr('total', 123.4) ... _ = cache.incr('count', 1) @@ -1344,8 +1349,7 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, See also `Cache.pull`. - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> print(cache.push('first value')) 500000000000000 >>> cache.get(500000000000000) @@ -1438,8 +1442,7 @@ def pull(self, prefix=None, default=(None, None), side='front', See also `Cache.push` and `Cache.get`. - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> cache.pull() (None, None) >>> for letter in 'abc': @@ -1555,8 +1558,7 @@ def peek(self, prefix=None, default=(None, None), side='front', See also `Cache.pull` and `Cache.push`. - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> for letter in 'abc': ... print(cache.push(letter)) 500000000000000 @@ -1654,8 +1656,7 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): Raises :exc:`Timeout` error when database timeout occurs and `retry` is `False` (default). - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> for num, letter in enumerate('abc'): ... cache[letter] = num >>> cache.peekitem() @@ -2043,8 +2044,7 @@ def _select_delete(self, select, args, row_index=0, arg_index=0, def iterkeys(self, reverse=False): """Iterate Cache keys in database sort order. - >>> cache = Cache('/tmp/diskcache') - >>> _ = cache.clear() + >>> cache = Cache() >>> for key in [4, 1, 3, 0, 2]: ... cache[key] = key >>> list(cache.iterkeys()) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index eb28e18..d6022ba 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -26,14 +26,13 @@ def __init__(self, directory, params): shards = params.get('SHARDS', 8) timeout = params.get('DATABASE_TIMEOUT', 0.010) options = params.get('OPTIONS', {}) - self._directory = directory self._cache = FanoutCache(directory, shards, timeout, **options) @property def directory(self): """Cache directory.""" - return self._directory + return self._cache.directory def cache(self, name): diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 1e48947..e89f0f6 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -4,6 +4,7 @@ import operator import os.path as op import sqlite3 +import tempfile import time try: @@ -18,7 +19,7 @@ class FanoutCache(object): "Cache that shards keys and values." - def __init__(self, directory, shards=8, timeout=0.010, disk=Disk, + def __init__(self, directory=None, shards=8, timeout=0.010, disk=Disk, **settings): """Initialize cache instance. @@ -29,13 +30,19 @@ def __init__(self, directory, shards=8, timeout=0.010, disk=Disk, :param settings: any of `DEFAULT_SETTINGS` """ - self._directory = directory - self._count = shards + if directory is None: + directory = tempfile.mkdtemp(prefix='diskcache-') + directory = op.expanduser(directory) + directory = op.expandvars(directory) + default_size_limit = DEFAULT_SETTINGS['size_limit'] size_limit = settings.pop('size_limit', default_size_limit) / shards + + self._count = shards + self._directory = directory self._shards = tuple( Cache( - op.join(directory, '%03d' % num), + directory=op.join(directory, '%03d' % num), timeout=timeout, disk=disk, size_limit=size_limit, @@ -564,9 +571,8 @@ def reset(self, key, value=ENOVAL): def cache(self, name): """Return Cache with given `name` in subdirectory. - >>> fanout_cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> fanout_cache = FanoutCache() >>> cache = fanout_cache.cache('test') - >>> _ = cache.clear() >>> cache.set('abc', 123) True >>> cache.get('abc') @@ -595,9 +601,8 @@ def cache(self, name): def deque(self, name): """Return Deque with given `name` in subdirectory. - >>> cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> cache = FanoutCache() >>> deque = cache.deque('test') - >>> deque.clear() >>> deque.extend('abc') >>> deque.popleft() 'a' @@ -625,9 +630,8 @@ def deque(self, name): def index(self, name): """Return Index with given `name` in subdirectory. - >>> cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> cache = FanoutCache() >>> index = cache.index('test') - >>> index.clear() >>> index['abc'] = 123 >>> index['def'] = 456 >>> index['ghi'] = 789 diff --git a/diskcache/memo.py b/diskcache/memo.py index 9f85738..84cac88 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -65,7 +65,7 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): for rewrapping the function with a different cache. >>> from diskcache import FanoutCache - >>> cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> cache = FanoutCache() >>> @cache.memoize(typed=True, expire=1, tag='fib') ... def fibonacci(number): ... if number == 0: diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 81b12f9..ab22e09 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -70,10 +70,7 @@ class Deque(Sequence): Items are serialized to disk. Deque may be initialized from directory path where items are stored. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque - Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += range(5) >>> list(deque) [0, 1, 2, 3, 4] @@ -102,8 +99,6 @@ def __init__(self, iterable=(), directory=None): :param directory: deque directory (default None) """ - if directory is None: - directory = mkdtemp() self._cache = Cache(directory, eviction_policy='none') with self.transact(): self.extend(iterable) @@ -113,8 +108,7 @@ def __init__(self, iterable=(), directory=None): def fromcache(cls, cache, iterable=()): """Initialize deque using `cache`. - >>> cache = Cache('/tmp/diskcache/index') - >>> _ = cache.clear() + >>> cache = Cache() >>> deque = Deque.fromcache(cache, [5, 6, 7, 8]) >>> deque.cache is cache True @@ -189,8 +183,7 @@ def __getitem__(self, index): See also `Deque.peekleft` and `Deque.peek` for indexing deque at index ``0`` or ``-1``. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.extend('abcde') >>> deque[1] 'b' @@ -210,8 +203,7 @@ def __setitem__(self, index, value): Store `value` in deque at `index`. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.extend([None] * 3) >>> deque[0] = 'a' >>> deque[1] = 'b' @@ -233,8 +225,7 @@ def __delitem__(self, index): Delete item in deque at `index`. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.extend([None] * 3) >>> del deque[0] >>> del deque[1] @@ -309,8 +300,7 @@ def __reversed__(self): Return iterator of deque from back to front. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.extend('abcd') >>> iterator = reversed(deque) >>> next(iterator) @@ -339,8 +329,7 @@ def __setstate__(self, state): def append(self, value): """Add `value` to back of deque. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.append('a') >>> deque.append('b') >>> deque.append('c') @@ -356,8 +345,7 @@ def append(self, value): def appendleft(self, value): """Add `value` to front of deque. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.appendleft('a') >>> deque.appendleft('b') >>> deque.appendleft('c') @@ -380,8 +368,7 @@ def clear(self): def count(self, value): """Return number of occurrences of `value` in deque. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += [num for num in range(1, 5) for _ in range(num)] >>> deque.count(0) 0 @@ -410,8 +397,7 @@ def extend(self, iterable): def extendleft(self, iterable): """Extend front side of deque with value from `iterable`. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.extendleft('abc') >>> list(deque) ['c', 'b', 'a'] @@ -430,8 +416,7 @@ def peek(self): If deque is empty then raise IndexError. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.peek() Traceback (most recent call last): ... @@ -458,8 +443,7 @@ def peekleft(self): If deque is empty then raise IndexError. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque.peekleft() Traceback (most recent call last): ... @@ -484,8 +468,7 @@ def pop(self): If deque is empty then raise IndexError. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += 'ab' >>> deque.pop() 'b' @@ -510,8 +493,7 @@ def pop(self): def popleft(self): """Remove and return value at front of deque. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += 'ab' >>> deque.popleft() 'a' @@ -536,8 +518,7 @@ def popleft(self): def remove(self, value): """Remove first occurrence of `value` in deque. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += 'aab' >>> deque.remove('a') >>> list(deque) @@ -575,8 +556,7 @@ def remove(self, value): def reverse(self): """Reverse deque in place. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += 'abc' >>> deque.reverse() >>> list(deque) @@ -601,8 +581,7 @@ def rotate(self, steps=1): If steps is negative then rotate left. - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += range(5) >>> deque.rotate(2) >>> list(deque) @@ -661,8 +640,7 @@ def transact(self): Transactions may be nested and may not be shared between threads. >>> from diskcache import Deque - >>> deque = Deque(directory='/tmp/diskcache/deque') - >>> deque.clear() + >>> deque = Deque() >>> deque += range(5) >>> with deque.transact(): # Atomically rotate elements. ... value = deque.pop() @@ -687,10 +665,7 @@ class Index(MutableMapping): Hashing protocol is not used. Keys are looked up by their serialized format. See ``diskcache.Disk`` for details. - >>> index = Index('/tmp/diskcache/index') - >>> index - Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> index['a'] 1 @@ -726,7 +701,7 @@ def __init__(self, *args, **kwargs): else: if args and args[0] is None: args = args[1:] - directory = mkdtemp(prefix='diskcache-') + directory = None self._cache = Cache(directory, eviction_policy='none') self.update(*args, **kwargs) @@ -735,8 +710,7 @@ def __init__(self, *args, **kwargs): def fromcache(cls, cache, *args, **kwargs): """Initialize index using `cache` and update items. - >>> cache = Cache('/tmp/diskcache/index') - >>> _ = cache.clear() + >>> cache = Cache() >>> index = Index.fromcache(cache, {'a': 1, 'b': 2, 'c': 3}) >>> index.cache is cache True @@ -777,8 +751,7 @@ def __getitem__(self, key): Return corresponding value for `key` in index. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2}) >>> index['a'] 1 @@ -802,8 +775,7 @@ def __setitem__(self, key, value): Set `key` and `value` item in index. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index['a'] = 1 >>> index[0] = None >>> len(index) @@ -821,8 +793,7 @@ def __delitem__(self, key): Delete corresponding item for `key` from index. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2}) >>> del index['a'] >>> del index['b'] @@ -846,8 +817,7 @@ def setdefault(self, key, default=None): If `key` is not in index then set corresponding value to `default`. If `key` is in index then ignore `default` and return existing value. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.setdefault('a', 0) 0 >>> index.setdefault('a', 1) @@ -869,8 +839,7 @@ def setdefault(self, key, default=None): def peekitem(self, last=True): """Peek at key and value item pair in index based on iteration order. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> for num, letter in enumerate('xyz'): ... index[letter] = num >>> index.peekitem() @@ -892,7 +861,7 @@ def pop(self, key, default=ENOVAL): If `key` is missing then return `default`. If `default` is `ENOVAL` then raise KeyError. - >>> index = Index('/tmp/diskcache/index', {'a': 1, 'b': 2}) + >>> index = Index({'a': 1, 'b': 2}) >>> index.pop('a') 1 >>> index.pop('b') @@ -924,8 +893,7 @@ def popitem(self, last=True): True else first-in-first-out (FIFO) order. LIFO order imitates a stack and FIFO order imitates a queue. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> index.popitem() ('c', 3) @@ -974,8 +942,7 @@ def push(self, value, prefix=None, side='back'): See also `Index.pull`. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> print(index.push('apples')) 500000000000000 >>> print(index.push('beans')) @@ -1010,8 +977,7 @@ def pull(self, prefix=None, default=(None, None), side='front'): See also `Index.push`. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> for letter in 'abc': ... print(index.push(letter)) 500000000000000 @@ -1059,8 +1025,7 @@ def __reversed__(self): Return iterator of index keys in reversed insertion order. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> iterator = reversed(index) >>> next(iterator) @@ -1085,8 +1050,7 @@ def __len__(self): def keys(self): """List of index keys. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> index.keys() ['a', 'b', 'c'] @@ -1100,8 +1064,7 @@ def keys(self): def values(self): """List of index values. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> index.values() [1, 2, 3] @@ -1115,8 +1078,7 @@ def values(self): def items(self): """List of index items. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> index.items() [('a', 1), ('b', 2), ('c', 3)] @@ -1130,8 +1092,7 @@ def items(self): def iterkeys(self): """Iterator of index keys. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> list(index.iterkeys()) ['a', 'b', 'c'] @@ -1145,8 +1106,7 @@ def iterkeys(self): def itervalues(self): """Iterator of index values. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> list(index.itervalues()) [1, 2, 3] @@ -1168,8 +1128,7 @@ def itervalues(self): def iteritems(self): """Iterator of index items. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> list(index.iteritems()) [('a', 1), ('b', 2), ('c', 3)] @@ -1191,8 +1150,7 @@ def iteritems(self): def viewkeys(self): """Set-like object providing a view of index keys. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> keys_view = index.viewkeys() >>> 'b' in keys_view @@ -1207,8 +1165,7 @@ def viewkeys(self): def viewvalues(self): """Set-like object providing a view of index values. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> values_view = index.viewvalues() >>> 2 in values_view @@ -1223,8 +1180,7 @@ def viewvalues(self): def viewitems(self): """Set-like object providing a view of index items. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> items_view = index.viewitems() >>> ('b', 2) in items_view @@ -1240,8 +1196,7 @@ def viewitems(self): def keys(self): """Set-like object providing a view of index keys. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> keys_view = index.keys() >>> 'b' in keys_view @@ -1256,8 +1211,7 @@ def keys(self): def values(self): """Set-like object providing a view of index values. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> values_view = index.values() >>> 2 in values_view @@ -1272,8 +1226,7 @@ def values(self): def items(self): """Set-like object providing a view of index items. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update({'a': 1, 'b': 2, 'c': 3}) >>> items_view = index.items() >>> ('b', 2) in items_view @@ -1304,8 +1257,7 @@ def __eq__(self, other): Comparison to another index or ordered dictionary is order-sensitive. Comparison to all other mappings is order-insensitive. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> pairs = [('a', 1), ('b', 2), ('c', 3)] >>> index.update(pairs) >>> from collections import OrderedDict @@ -1339,8 +1291,7 @@ def __ne__(self, other): Comparison to another index or ordered dictionary is order-sensitive. Comparison to all other mappings is order-insensitive. - >>> index = Index('/tmp/diskcache/index') - >>> index.clear() + >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) >>> from collections import OrderedDict >>> od = OrderedDict([('c', 3), ('b', 2), ('a', 1)]) @@ -1375,7 +1326,7 @@ def memoize(self, name=None, typed=False): or for rewrapping the function with a different cache. >>> from diskcache import Index - >>> mapping = Index('/tmp/diskcache/index') + >>> mapping = Index() >>> @mapping.memoize(typed=True) ... def fibonacci(number): ... if number == 0: @@ -1418,7 +1369,7 @@ def transact(self): Transactions may be nested and may not be shared between threads. >>> from diskcache import Index - >>> mapping = Index('/tmp/diskcache/index') + >>> mapping = Index() >>> with mapping.transact(): # Atomically increment two keys. ... mapping['total'] = mapping.get('total', 0) + 123.4 ... mapping['count'] = mapping.get('count', 0) + 1 diff --git a/diskcache/recipes.py b/diskcache/recipes.py index d729cf5..4525fe8 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -1,7 +1,7 @@ """Disk Cache Recipes >>> import diskcache as dc, time ->>> cache = dc.Cache('/tmp/diskcache/example') +>>> cache = dc.Cache() >>> @dc.memoize(cache) ... @dc.barrier(cache, dc.Lock) ... @dc.memoize(cache) @@ -43,7 +43,7 @@ class Averager(object): total and count. The average can then be calculated at any time. >>> import diskcache - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> ave = Averager(cache, 'latency') >>> ave.add(0.080) >>> ave.add(0.120) @@ -85,7 +85,7 @@ class Lock(object): """Recipe for cross-process and cross-thread lock. >>> import diskcache - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> lock = Lock(cache, 'report-123') >>> lock.acquire() >>> lock.release() @@ -124,7 +124,7 @@ class RLock(object): """Recipe for cross-process and cross-thread re-entrant lock. >>> import diskcache - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> rlock = RLock(cache, 'user-123') >>> rlock.acquire() >>> rlock.acquire() @@ -182,7 +182,7 @@ class BoundedSemaphore(object): """Recipe for cross-process and cross-thread bounded semaphore. >>> import diskcache - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> semaphore = BoundedSemaphore(cache, 'max-connections', value=2) >>> semaphore.acquire() >>> semaphore.acquire() @@ -238,7 +238,7 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, """Decorator to throttle calls to function. >>> import diskcache, time - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> @throttle(cache, 1, 1) ... def int_time(): ... return int(time.time()) @@ -295,7 +295,7 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): """Barrier to calling decorated function. >>> import diskcache, time - >>> cache = diskcache.Cache('/tmp/diskcache/recipes') + >>> cache = diskcache.Cache() >>> @barrier(cache, Lock) ... def work(num): ... time.sleep(1) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index c168cf7..20aaaaa 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -68,7 +68,7 @@ represents a disk and file backed cache. As a Cache, it supports a familiar Python Mapping interface with additional cache and performance parameters. >>> from diskcache import Cache - >>> cache = Cache('/tmp/mycachedir') + >>> cache = Cache() Initialization requires a directory path reference. If the directory path does not exist, it will be created. Additional keyword parameters are discussed @@ -85,7 +85,7 @@ in a `with` statement to safeguard calling :meth:`close `. >>> cache.close() - >>> with Cache('/tmp/mycachedir') as reference: + >>> with Cache(cache.directory) as reference: ... pass Closed Cache objects will automatically re-open when accessed. But opening @@ -100,7 +100,6 @@ safely leave Cache objects open. Set an item, get a value, and delete a key using the usual operators: - >>> cache = Cache('/tmp/mycachedir') >>> cache[b'key'] = b'value' >>> cache[b'key'] b'value' @@ -227,7 +226,8 @@ tag. The default tag is ``None``. Tag values may be any of integer, float, string, bytes and None. To accelerate the eviction of items by tag, an index can be created. To do so, initialize the cache with ``tag_index=True``. - >>> cache = Cache('/tmp/mycachedir', tag_index=True) + >>> cache.clear() + 50 >>> for num in range(100): ... _ = cache.set(num, num, tag=(num % 2)) >>> cache.evict(0) @@ -311,6 +311,9 @@ The third is :meth:`check ` which verifies cache consistency. It can also fix inconsistencies and reclaim unused space. The return value is a list of warnings. + >>> import shutil + >>> shutil.rmtree(cache.directory) + .. _tutorial-fanoutcache: FanoutCache @@ -343,20 +346,19 @@ when :exc:`Timeout ` errors occur. :class:`FanoutCache exception. The default `timeout` is 0.010 (10 milliseconds). >>> from diskcache import FanoutCache - >>> cache = FanoutCache('/tmp/mycachedir', shards=4, timeout=1) + >>> cache = FanoutCache(shards=4, timeout=1) -The example above creates a cache in the local ``/tmp/mycachedir`` directory -with four shards and a one second timeout. Operations will attempt to abort if -they take longer than one second. The remaining API of :class:`FanoutCache -` matches :class:`Cache ` as described -above. +The example above creates a cache in a temporary directory with four shards and +a one second timeout. Operations will attempt to abort if they take longer than +one second. The remaining API of :class:`FanoutCache ` +matches :class:`Cache ` as described above. :class:`FanoutCache ` adds an additional feature: :meth:`memoizing ` cache decorator. The decorator wraps a callable and caches arguments and return values. >>> from diskcache import FanoutCache - >>> cache = FanoutCache('/tmp/diskcache/fanoutcache') + >>> cache = FanoutCache() >>> @cache.memoize(typed=True, expire=1, tag='fib') ... def fibonacci(number): ... if number == 0: @@ -547,9 +549,7 @@ are updated lazily. Prefer idioms like :meth:`len ` rather than using :meth:`reset ` directly. - >>> cache = Cache('/tmp/mycachedir', size_limit=int(4e9)) - >>> cache.clear() - 100 + >>> cache = Cache(size_limit=int(4e9)) >>> cache.size_limit 4000000000 >>> cache.disk_min_file_size @@ -559,7 +559,7 @@ are updated lazily. Prefer idioms like :meth:`len >>> cache.set(b'key', 1.234) True >>> cache.count # Stale attribute. - 100 + 0 >>> cache.reset('count') # Prefer: len(cache) 1 @@ -615,10 +615,10 @@ tradeoffs for accessing and storing items. All clients accessing the cache are expected to use the same eviction policy. The policy can be set during initialization using a keyword argument. - >>> cache = Cache('/tmp/mydir') + >>> cache = Cache() >>> cache.eviction_policy 'least-recently-stored' - >>> cache = Cache('/tmp/mydir', eviction_policy='least-frequently-used') + >>> cache = Cache(eviction_policy='least-frequently-used') >>> cache.eviction_policy 'least-frequently-used' >>> cache.reset('eviction_policy', 'least-recently-used') @@ -673,7 +673,7 @@ example below uses compressed JSON. data = json.loads(zlib.decompress(data).decode('utf-8')) return data - with Cache('/tmp/mydir', disk=JSONDisk, disk_compress_level=6) as cache: + with Cache(disk=JSONDisk, disk_compress_level=6) as cache: pass Four data types can be stored natively in the cache metadata database: diff --git a/tests/test_core.py b/tests/test_core.py index c440e0f..bcaea05 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -17,6 +17,7 @@ import sqlite3 import subprocess as sp import sys +import tempfile import threading import time import unittest @@ -38,10 +39,9 @@ @pytest.fixture def cache(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.Cache('tmp') as cache: + with dc.Cache() as cache: yield cache - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_init(cache): @@ -53,18 +53,17 @@ def test_init(cache): def test_init_disk(): - with dc.Cache('tmp', disk_pickle_protocol=1, disk_min_file_size=2 ** 20) as cache: + with dc.Cache(disk_pickle_protocol=1, disk_min_file_size=2 ** 20) as cache: key = (None, 0, 'abc') cache[key] = 0 cache.check() - assert cache.directory == 'tmp' assert cache.disk_min_file_size == 2 ** 20 assert cache.disk_pickle_protocol == 1 - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_disk_reset(): - with dc.Cache('tmp', disk_min_file_size=0, disk_pickle_protocol=0) as cache: + with dc.Cache(disk_min_file_size=0, disk_pickle_protocol=0) as cache: value = (None, 0, 'abc') cache[0] = value @@ -86,12 +85,12 @@ def test_disk_reset(): assert cache._disk.min_file_size == 2 ** 10 assert cache._disk.pickle_protocol == 2 - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_disk_valueerror(): with pytest.raises(ValueError): - with dc.Cache('tmp', disk=dc.Disk('tmp')) as cache: + with dc.Cache(disk=dc.Disk('test')): pass @@ -123,7 +122,7 @@ def fetch(self, mode, filename, value, read): def test_custom_disk(): - with dc.Cache('tmp', disk=JSONDisk, disk_compress_level=6) as cache: + with dc.Cache(disk=JSONDisk, disk_compress_level=6) as cache: values = [None, True, 0, 1.23, {}, [None] * 10000] for value in values: @@ -132,7 +131,7 @@ def test_custom_disk(): for value in values: assert cache[value] == value - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) class SHA256FilenameDisk(diskcache.Disk): @@ -143,7 +142,7 @@ def filename(self, key=dc.UNKNOWN, value=dc.UNKNOWN): def test_custom_filename_disk(): - with dc.Cache('tmp', disk=SHA256FilenameDisk) as cache: + with dc.Cache(disk=SHA256FilenameDisk) as cache: for count in range(100, 200): key = str(count).encode('ascii') cache[key] = str(count) * int(1e5) @@ -151,25 +150,26 @@ def test_custom_filename_disk(): for count in range(100, 200): key = str(count).encode('ascii') filename = hashlib.sha256(key).hexdigest()[:32] - full_path = op.join('tmp', filename) + full_path = op.join(cache.directory, filename) with open(full_path) as reader: content = reader.read() assert content == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_init_makedirs(): - shutil.rmtree('tmp', ignore_errors=True) + cache_dir = tempfile.mkdtemp() + shutil.rmtree(cache_dir) makedirs = mock.Mock(side_effect=OSError(errno.EACCES)) with pytest.raises(EnvironmentError): try: with mock.patch('os.makedirs', makedirs): - cache = dc.Cache('tmp') + cache = dc.Cache(cache_dir) except EnvironmentError: - shutil.rmtree('tmp') + shutil.rmtree(cache_dir, ignore_errors=True) raise @@ -689,11 +689,11 @@ def test_integrity_check(cache): cache.close() - with io.open('tmp/cache.db', 'r+b') as writer: + with io.open(op.join(cache.directory, 'cache.db'), 'r+b') as writer: writer.seek(52) writer.write(b'\x00\x01') # Should be 0, change it. - cache = dc.Cache('tmp') + cache = dc.Cache(cache.directory) with warnings.catch_warnings(): warnings.filterwarnings('ignore') @@ -724,9 +724,9 @@ def test_expire(cache): def test_tag_index(): - with dc.Cache('tmp', tag_index=True) as cache: + with dc.Cache(tag_index=True) as cache: assert cache.tag_index == 1 - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_evict(cache): @@ -771,7 +771,7 @@ def test_tag(cache): def test_with(cache): - with dc.Cache('tmp') as tmp: + with dc.Cache(cache.directory) as tmp: tmp[u'a'] = 0 tmp[u'b'] = 1 @@ -1323,7 +1323,7 @@ def test_constant(): def test_copy(): - cache_dir1 = op.join('tmp', 'foo') + cache_dir1 = tempfile.mkdtemp() with dc.Cache(cache_dir1) as cache1: for count in range(10): @@ -1332,7 +1332,8 @@ def test_copy(): for count in range(10, 20): cache1[count] = str(count) * int(1e5) - cache_dir2 = op.join('tmp', 'bar') + cache_dir2 = tempfile.mkdtemp() + shutil.rmtree(cache_dir2) shutil.copytree(cache_dir1, cache_dir2) with dc.Cache(cache_dir2) as cache2: @@ -1342,7 +1343,8 @@ def test_copy(): for count in range(10, 20): assert cache2[count] == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache_dir1, ignore_errors=True) + shutil.rmtree(cache_dir2, ignore_errors=True) def run(command): @@ -1362,8 +1364,8 @@ def test_rsync(): return # No rsync installed. Skip test. rsync_args = ['rsync', '-a', '--checksum', '--delete', '--stats'] - cache_dir1 = op.join('tmp', 'foo') + os.sep - cache_dir2 = op.join('tmp', 'bar') + os.sep + cache_dir1 = tempfile.mkdtemp() + os.sep + cache_dir2 = tempfile.mkdtemp() + os.sep # Store some items in cache_dir1. @@ -1415,7 +1417,8 @@ def test_rsync(): for count in range(300, 400): assert cache1[count] == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache_dir1, ignore_errors=True) + shutil.rmtree(cache_dir2, ignore_errors=True) def test_custom_eviction_policy(cache): diff --git a/tests/test_deque.py b/tests/test_deque.py index cdb97ed..a3ac018 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -20,11 +20,8 @@ def rmdir(directory): @pytest.fixture def deque(): deque = dc.Deque() - try: - yield deque - except Exception: - rmdir(deque.directory) - raise + yield deque + rmdir(deque.directory) def test_init(): diff --git a/tests/test_doctest.py b/tests/test_doctest.py index d90d548..d171e48 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -10,39 +10,27 @@ import diskcache.recipes -def rmdir(directory): - try: - shutil.rmtree(directory) - except OSError: - pass - - def test_core(): - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.core) assert failures == 0 def test_djangocache(): - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.djangocache) assert failures == 0 def test_fanout(): - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.fanout) assert failures == 0 def test_memo(): - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.memo) assert failures == 0 def test_persistent(): - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.persistent) assert failures == 0 @@ -50,8 +38,6 @@ def test_persistent(): def test_tutorial(): if sys.hexversion < 0x03000000: return - rmdir('/tmp/mycachedir') - rmdir('/tmp/mydir') failures, _ = doctest.testfile('../docs/tutorial.rst') assert failures == 0 @@ -59,6 +45,5 @@ def test_tutorial(): def test_recipes(): if sys.hexversion < 0x03000000: return - rmdir('/tmp/diskcache') failures, _ = doctest.testmod(diskcache.recipes) assert failures == 0 diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 89f3a58..1d96d13 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -16,6 +16,7 @@ import sqlite3 import subprocess as sp import sys +import tempfile import threading import time import warnings @@ -36,15 +37,12 @@ @pytest.fixture def cache(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.FanoutCache('tmp') as cache: + with dc.FanoutCache() as cache: yield cache - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_init(cache): - assert cache.directory == 'tmp' - default_settings = dc.DEFAULT_SETTINGS.copy() del default_settings['size_limit'] for key, value in default_settings.items(): @@ -160,8 +158,7 @@ def stress_add(cache, limit, results): def test_add_concurrent(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.FanoutCache('tmp', shards=1) as cache: + with dc.FanoutCache(shards=1) as cache: results = co.deque() limit = 1000 @@ -178,7 +175,7 @@ def test_add_concurrent(): assert sum(results) == limit cache.check() - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_incr(cache): @@ -222,8 +219,7 @@ def stress_incr(cache, limit): def test_incr_concurrent(): - shutil.rmtree('tmp', ignore_errors=True) - with dc.FanoutCache('tmp', shards=1, timeout=0.001) as cache: + with dc.FanoutCache(shards=1, timeout=0.001) as cache: count = 16 limit = 50 @@ -240,7 +236,7 @@ def test_incr_concurrent(): assert cache.get(b'key') == count * limit cache.check() - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) def test_getsetdel(cache): @@ -568,7 +564,7 @@ def fibrec(num): def test_copy(): - cache_dir1 = op.join('tmp', 'foo') + cache_dir1 = tempfile.mkdtemp() with dc.FanoutCache(cache_dir1) as cache1: for count in range(10): @@ -577,7 +573,8 @@ def test_copy(): for count in range(10, 20): cache1[count] = str(count) * int(1e5) - cache_dir2 = op.join('tmp', 'bar') + cache_dir2 = tempfile.mkdtemp() + shutil.rmtree(cache_dir2) shutil.copytree(cache_dir1, cache_dir2) with dc.FanoutCache(cache_dir2) as cache2: @@ -587,7 +584,8 @@ def test_copy(): for count in range(10, 20): assert cache2[count] == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache_dir1, ignore_errors=True) + shutil.rmtree(cache_dir2, ignore_errors=True) def run(command): @@ -607,8 +605,8 @@ def test_rsync(): return # No rsync installed. Skip test. rsync_args = ['rsync', '-a', '--checksum', '--delete', '--stats'] - cache_dir1 = op.join('tmp', 'foo') + os.sep - cache_dir2 = op.join('tmp', 'bar') + os.sep + cache_dir1 = tempfile.mkdtemp() + os.sep + cache_dir2 = tempfile.mkdtemp() + os.sep # Store some items in cache_dir1. @@ -660,7 +658,8 @@ def test_rsync(): for count in range(300, 400): assert cache1[count] == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache_dir1, ignore_errors=True) + shutil.rmtree(cache_dir2, ignore_errors=True) class SHA256FilenameDisk(dc.Disk): @@ -671,24 +670,24 @@ def filename(self, key=dc.UNKNOWN, value=dc.UNKNOWN): def test_custom_filename_disk(): - with dc.FanoutCache('tmp', disk=SHA256FilenameDisk) as cache: + with dc.FanoutCache(disk=SHA256FilenameDisk) as cache: for count in range(100, 200): key = str(count).encode('ascii') cache[key] = str(count) * int(1e5) - disk = SHA256FilenameDisk('tmp') + disk = SHA256FilenameDisk(cache.directory) for count in range(100, 200): key = str(count).encode('ascii') subdir = '%03d' % (disk.hash(key) % 8) filename = hashlib.sha256(key).hexdigest()[:32] - full_path = op.join('tmp', subdir, filename) + full_path = op.join(cache.directory, subdir, filename) with open(full_path) as reader: content = reader.read() assert content == str(count) * int(1e5) - shutil.rmtree('tmp', ignore_errors=True) + shutil.rmtree(cache.directory, ignore_errors=True) if __name__ == '__main__': diff --git a/tests/test_index.py b/tests/test_index.py index 2b4aa14..83ba5cf 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -20,11 +20,8 @@ def rmdir(directory): @pytest.fixture def index(): index = dc.Index() - try: - yield index - except Exception: - rmdir(index.directory) - raise + yield index + rmdir(index.directory) def test_init(): From 62417f84e3c3b76c92fe7286f9816cf9b6e98842 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 3 Apr 2019 11:08:48 -0700 Subject: [PATCH 101/329] Add make_key attribute to memoized functions --- diskcache/djangocache.py | 8 ++++++-- diskcache/memo.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index d6022ba..bc6a15f 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -10,7 +10,7 @@ DEFAULT_TIMEOUT = 300 from .fanout import FanoutCache -from .memo import MARK, args_to_key, full_name +from .memo import MARK, _args_to_key, full_name class DjangoCache(BaseCache): @@ -399,7 +399,7 @@ def decorator(func): @wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." - key = args_to_key(base, args, kwargs, typed) + key = wrapper.make_key(args, kwargs) result = self.get(key, MARK, version, retry=True) if result is MARK: @@ -408,6 +408,10 @@ def wrapper(*args, **kwargs): return result + def make_key(args, kwargs): + return _args_to_key(base, args, kwargs, typed) + + wrapper.make_key = make_key return wrapper return decorator diff --git a/diskcache/memo.py b/diskcache/memo.py index 84cac88..f5bfdfc 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -18,7 +18,7 @@ def full_name(func): return func.__module__ + '.' + name -def args_to_key(base, args, kwargs, typed): +def _args_to_key(base, args, kwargs, typed): """Create cache key out of function arguments. :param tuple base: base of key @@ -107,7 +107,7 @@ def decorator(func): @wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." - key = args_to_key(base, args, kwargs, typed) + key = wrapper.make_key(args, kwargs) result = cache.get(key, default=MARK, retry=True) if result is MARK: @@ -116,6 +116,10 @@ def wrapper(*args, **kwargs): return result + def make_key(args, kwargs): + return _args_to_key(base, args, kwargs, typed) + + wrapper.make_key = make_key return wrapper return decorator From d1591ff5f390377099b028191143a45b6c87d11c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 5 Apr 2019 10:21:05 -0700 Subject: [PATCH 102/329] Small docstring improvements. --- diskcache/recipes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 4525fe8..e5da48c 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -7,14 +7,14 @@ ... @dc.memoize(cache) ... def work(num): ... time.sleep(1) -... return -num +... return num >>> from concurrent.futures import ThreadPoolExecutor >>> with ThreadPoolExecutor() as executor: ... start = time.time() ... times = list(executor.map(work, range(5))) ... end = time.time() >>> times -[0, -1, -2, -3, -4] +[0, 1, 2, 3, 4] >>> int(end - start) 5 >>> with ThreadPoolExecutor() as executor: @@ -22,7 +22,7 @@ ... times = list(executor.map(work, range(5))) ... end = time.time() >>> times -[0, -1, -2, -3, -4] +[0, 1, 2, 3, 4] >>> int(end - start) 0 @@ -294,6 +294,8 @@ def wrapper(*args, **kwargs): def barrier(cache, lock_factory, name=None, expire=None, tag=None): """Barrier to calling decorated function. + Supports different kinds of locks: Lock, RLock, BoundedSemaphore. + >>> import diskcache, time >>> cache = diskcache.Cache() >>> @barrier(cache, Lock) From 6cf31dc840e7c2e6e209fb3b54840cb0b0f02320 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 5 Apr 2019 16:49:22 -0700 Subject: [PATCH 103/329] Add early recomputation option for memoization --- diskcache/djangocache.py | 79 ++++++++++++++++++++++++++++++------- diskcache/memo.py | 84 +++++++++++++++++++++++++++++++++------- diskcache/stampede.py | 78 ------------------------------------- 3 files changed, 135 insertions(+), 106 deletions(-) delete mode 100644 diskcache/stampede.py diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index bc6a15f..fa2e061 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -1,6 +1,7 @@ "Django-compatible disk and file backed cache." from functools import wraps +from time import time from django.core.cache.backends.base import BaseCache try: @@ -358,7 +359,7 @@ def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT): def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, - typed=False, tag=None): + typed=False, tag=None, early_recompute=False, time_func=time): """Memoizing cache decorator. Decorator to wrap callable with memoizing function using cache. @@ -372,12 +373,29 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. + Cache stampedes are a type of cascading failure that can occur when + parallel computing systems using memoization come under heavy + load. This behaviour is sometimes also called dog-piling, cache miss + storm, cache choking, or the thundering herd problem. + + The memoization decorator includes cache stampede protection through + the early recomputation parameter. When set to True (default False), + the expire parameter must not be None. Early recomputation of results + will occur probabilistically before expiration. + + Early probabilistic recomputation is based on research by Vattani, A.; + Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache + Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 + The original underlying function is accessible through the __wrapped__ attribute. This is useful for introspection, for bypassing the cache, or for rewrapping the function with a different cache. - Remember to call memoize when decorating a callable. If you forget, then a - TypeError will occur. + An additional `__cache_key__` attribute can be used to generate the + cache key used for the given arguments. + + Remember to call memoize when decorating a callable. If you forget, + then a TypeError will occur. :param str name: name given for callable (default None, automatic) :param float timeout: seconds until the item expires @@ -385,6 +403,9 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, :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 bool early_recompute: probabilistic early recomputation + (default False) + :param time_func: callable for calculating current time :return: callable decorator """ @@ -392,26 +413,56 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, if callable(name): raise TypeError('name cannot be callable') + if early_recompute and expire is None: + raise ValueError('expire required') + def decorator(func): "Decorator created by memoize call for callable." 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." - key = wrapper.make_key(args, kwargs) - result = self.get(key, MARK, version, retry=True) + if early_recompute: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + pair, expire_time = self.get( + key, MARK, version, expire_time=True, retry=True, + ) - if result is MARK: - result = func(*args, **kwargs) - self.set(key, result, timeout, version, tag=tag, retry=True) + if pair is not MARK: + result, delta = pair + now = time_func() + ttl = expire_time - now - return result + if (-delta * log(random())) < ttl: + return result - def make_key(args, kwargs): + start = time_func() + result = func(*args, **kwargs) + delta = time_func() - start + pair = result, delta + self.set(key, pair, timeout, version, tag=tag, retry=True) + return result + else: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + result = self.get(key, MARK, version, retry=True) + + if result is MARK: + result = func(*args, **kwargs) + self.set( + key, result, timeout, version, tag=tag, retry=True, + ) + + return result + + def __cache_key__(*args, **kwargs): + "Make key for cache given function arguments." return _args_to_key(base, args, kwargs, typed) - wrapper.make_key = make_key + wrapper.__cache_key__ = __cache_key__ return wrapper return decorator diff --git a/diskcache/memo.py b/diskcache/memo.py index f5bfdfc..607c01a 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -3,6 +3,9 @@ """ from functools import wraps +from math import log +from random import random +from time import time MARK = object() @@ -46,7 +49,8 @@ def _args_to_key(base, args, kwargs, typed): return key -def memoize(cache, name=None, typed=False, expire=None, tag=None): +def memoize(cache, name=None, typed=False, expire=None, tag=None, + early_recompute=False, time_func=time): """Memoizing cache decorator. Decorator to wrap callable with memoizing function using cache. Repeated @@ -60,6 +64,20 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. + Cache stampedes are a type of cascading failure that can occur when + parallel computing systems using memoization come under heavy load. This + behaviour is sometimes also called dog-piling, cache miss storm, cache + choking, or the thundering herd problem. + + The memoization decorator includes cache stampede protection through the + early recomputation parameter. When set to True (default False), the expire + parameter must not be None. Early recomputation of results will occur + probabilistically before expiration. + + Early probabilistic recomputation is based on research by Vattani, A.; + Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache + Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 + The original underlying function is accessible through the __wrapped__ attribute. This is useful for introspection, for bypassing the cache, or for rewrapping the function with a different cache. @@ -74,8 +92,15 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): ... return 1 ... else: ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> print(sum(fibonacci(number=value) for value in range(100))) - 573147844013817084100 + >>> fibonacci(100) + 354224848179261915075 + + An additional `__cache_key__` attribute can be used to generate the cache key + used for the given arguments. + + >>> key = fibonacci.__cache_key__(100) + >>> cache[key] + 354224848179261915075 Remember to call memoize when decorating a callable. If you forget, then a TypeError will occur. Note the lack of parenthenses after memoize below: @@ -93,6 +118,9 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): :param float expire: seconds until arguments expire (default None, no expiry) :param str tag: text to associate with arguments (default None) + :param bool early_recompute: probabilistic early recomputation + (default False) + :param time_func: callable for calculating current time :return: callable decorator """ @@ -100,26 +128,54 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None): if callable(name): raise TypeError('name cannot be callable') + if early_recompute and expire is None: + raise ValueError('expire required') + def decorator(func): "Decorator created by memoize call for callable." 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." - key = wrapper.make_key(args, kwargs) - result = cache.get(key, default=MARK, retry=True) + if early_recompute: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + pair, expire_time = cache.get( + key, default=MARK, expire_time=True, retry=True, + ) - if result is MARK: - result = func(*args, **kwargs) - cache.set(key, result, expire=expire, tag=tag, retry=True) + if pair is not MARK: + result, delta = pair + now = time_func() + ttl = expire_time - now - return result + if (-delta * log(random())) < ttl: + return result - def make_key(args, kwargs): + start = time_func() + result = func(*args, **kwargs) + delta = time_func() - start + pair = result, delta + cache.set(key, pair, expire=expire, tag=tag, retry=True) + return result + else: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + result = cache.get(key, default=MARK, retry=True) + + if result is MARK: + result = func(*args, **kwargs) + cache.set(key, result, expire=expire, tag=tag, retry=True) + + return result + + def __cache_key__(*args, **kwargs): + "Make key for cache given function arguments." return _args_to_key(base, args, kwargs, typed) - wrapper.make_key = make_key + wrapper.__cache_key__ = __cache_key__ return wrapper return decorator diff --git a/diskcache/stampede.py b/diskcache/stampede.py deleted file mode 100644 index b103338..0000000 --- a/diskcache/stampede.py +++ /dev/null @@ -1,78 +0,0 @@ -"Stampede barrier implementation." - -import functools as ft -import math -import random -import tempfile -import time - -from .core import Cache, ENOVAL - - -class StampedeBarrier(object): - """Stampede barrier mitigates cache stampedes. - - Cache stampedes are also known as dog-piling, cache miss storm, cache - choking, or the thundering herd problem. - - Based on research by Vattani, A.; Chierichetti, F.; Lowenstein, K. (2015), - Optimal Probabilistic Cache Stampede Prevention, - VLDB, pp. 886?897, ISSN 2150-8097 - - Example: - - ```python - stampede_barrier = StampedeBarrier('/tmp/user_data', expire=3) - - @stampede_barrier - def load_user_info(user_id): - return database.lookup_user_info_by_id(user_id) - ``` - - """ - # pylint: disable=too-few-public-methods - def __init__(self, cache=None, expire=None): - if isinstance(cache, Cache): - pass - elif cache is None: - cache = Cache(tempfile.mkdtemp()) - else: - cache = Cache(cache) - - self._cache = cache - self._expire = expire - - def __call__(self, func): - cache = self._cache - expire = self._expire - - @ft.wraps(func) - def wrapper(*args, **kwargs): - "Wrapper function to cache function result." - key = (args, kwargs) - - try: - result, expire_time, delta = cache.get( - key, default=ENOVAL, expire_time=True, tag=True - ) - - if result is ENOVAL: - raise KeyError - - now = time.time() - ttl = expire_time - now - - if (-delta * math.log(random.random())) < ttl: - return result - - except KeyError: - pass - - now = time.time() - result = func(*args, **kwargs) - delta = time.time() - now - cache.set(key, result, expire=expire, tag=delta) - - return result - - return wrapper From ce44c40a548abc524dad540e41887e15991e2e44 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 9 Apr 2019 12:16:21 -0700 Subject: [PATCH 104/329] Add script for testing early recomputation --- diskcache/core.py | 3 + diskcache/memo.py | 2 +- tests/test_early_recompute.py | 141 ++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/test_early_recompute.py diff --git a/diskcache/core.py b/diskcache/core.py index f86cf66..deef26c 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -707,6 +707,9 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): :raises Timeout: if database timeout occurs """ + if expire is not None and expire <= 0: + return False + now = time.time() db_key, raw = self._disk.put(key) expire_time = None if expire is None else now + expire diff --git a/diskcache/memo.py b/diskcache/memo.py index 607c01a..9d4fc24 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -149,7 +149,7 @@ def wrapper(*args, **kwargs): now = time_func() ttl = expire_time - now - if (-delta * log(random())) < ttl: + if (-delta * early_recompute * log(random())) < ttl: return result start = time_func() diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py new file mode 100644 index 0000000..2ae8ffe --- /dev/null +++ b/tests/test_early_recompute.py @@ -0,0 +1,141 @@ +"""Early Recomputation Measurements + +TODO + +* Publish graphs: + 1. Cache stampede (single memo decorator). + 2. Double-checked locking (memo, barrier, memo). + 3. Early recomputation (memo with early recomputation). + 4. Advanced usage: adjust "Beta" parameter. + +""" + +import concurrent.futures +import diskcache as dc +import functools +import matplotlib.pyplot as plt +import shutil +import threading +import time + + +def make_timer(times): + """Make a decorator which accumulates (start, end) in `times` for function + calls. + + """ + lock = threading.Lock() + def timer(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + start = time.time() + result = func(*args, **kwargs) + pair = start, time.time() + with lock: + times.append(pair) + return wrapper + return timer + + +def make_worker(times, delay=1): + """Make a worker which accumulates (start, end) in `times` and sleeps for + `delay` seconds. + + """ + @make_timer(times) + def worker(): + time.sleep(delay) + return worker + + +def make_repeater(func, total=60, delay=0.01): + """Make a repeater which calls `func` and sleeps for `delay` seconds + repeatedly until `total` seconds have elapsed. + + """ + def repeat(num): + start = time.time() + while time.time() - start < total: + func() + time.sleep(delay) + return repeat + + +def frange(start, stop, step=1e-3): + "Generator for floating point values from `start` to `stop` by `step`." + while start < stop: + yield start + start += step + + +def plot(cache_times, worker_times): + "Plot concurrent workers and latency." + fig, (workers, latency) = plt.subplots(2, sharex=True) + + changes = [(start, 1) for start, _ in worker_times] + changes.extend((stop, -1) for _, stop in worker_times) + changes.sort() + start = (changes[0][0] - 1e-6, 0) + counts = [start] + + for mark, diff in changes: + # Re-sample between previous and current data point for a nicer-looking + # line plot. + + for step in frange(counts[-1][0], mark): + pair = (step, counts[-1][1]) + counts.append(pair) + + pair = (mark, counts[-1][1] + diff) + counts.append(pair) + + max_x = max(start for start, _ in cache_times) + for step in frange(counts[-1][0], max_x): + pair = (step, counts[-1][1]) + counts.append(pair) + + x_counts = [x for x, y in counts] + y_counts = [y for x, y in counts] + + workers.set_title('Concurrent Workers') + workers.set_ylabel('Workers') + workers.plot(x_counts, y_counts) + + latency.set_title('Latency') + latency.set_ylabel('Seconds') + latency.set_xlabel('Time') + x_latency = [start for start, _ in cache_times] + y_latency = [stop - start for start, stop in cache_times] + latency.scatter(x_latency, y_latency) + + plt.show() + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser() + + shutil.rmtree('/tmp/cache', ignore_errors=True) + cache = dc.Cache('/tmp/cache') + + count = 16 + + cache_times = [] + worker_times = [] + + worker = make_worker(worker_times) + decorators = [ + make_timer(cache_times), + cache.memoize(expire=10, early_recompute=1.5), + # dc.barrier(cache, dc.Lock), + # cache.memoize(expire=10), + ] + for decorator in reversed(decorators): + worker = decorator(worker) + + repeater = make_repeater(worker) + + with concurrent.futures.ThreadPoolExecutor(count) as executor: + executor.map(repeater, [worker] * count) + + plot(cache_times, worker_times) From 78977bf2ab2d9de84ccc09d32ed735da0b061ffb Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 19 Apr 2019 10:06:01 -0700 Subject: [PATCH 105/329] Add benchmarking script from issue 109 --- tests/issue_109.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/issue_109.py diff --git a/tests/issue_109.py b/tests/issue_109.py new file mode 100644 index 0000000..de03cf9 --- /dev/null +++ b/tests/issue_109.py @@ -0,0 +1,51 @@ +from time import time, sleep +from diskcache import FanoutCache + +# Initialise the maximum delay reference +max_delay = 0 + +# Initialise the reference to current time +# Add a significant amount of time to ignore the first measurement due to other initialisations +current_time = time() + 100 + +# Initialise the Fanout Cache instance +data = FanoutCache('test', shards=8) + + +# Declare the function used to modify the data +def set_data(**kwargs): + """ + + Function used to modify the cache. + + :param kwargs: Key, value pairs of data to modify. + + """ + + # Fetch the global variables + global max_delay + global current_time + global data + + # Update the data with the given keyword arguments + for key, value in kwargs.items(): + + # Measure the time taken to update it for each item + temp = time() + dif = temp - current_time + if dif > max_delay: + max_delay = dif + print("New max delay encountered: ", max_delay) + current_time = temp + + # Update the data + data[key] = value + + +# Initialise some test values +test_values = {str(key): value for key in range(25) for value in range(25)} + +# Keep writing the data in 0.1 sec intervals +while True: + sleep(0.1) + set_data(**test_values) From 65b648e4a3174a2178533a4b0bcd9bf52cdc0a96 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 19 Apr 2019 21:36:14 -0700 Subject: [PATCH 106/329] Measure FanoutCache performance for Issue #109 --- tests/issue_109.py | 101 ++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/tests/issue_109.py b/tests/issue_109.py index de03cf9..a650b4c 100644 --- a/tests/issue_109.py +++ b/tests/issue_109.py @@ -1,51 +1,50 @@ -from time import time, sleep -from diskcache import FanoutCache - -# Initialise the maximum delay reference -max_delay = 0 - -# Initialise the reference to current time -# Add a significant amount of time to ignore the first measurement due to other initialisations -current_time = time() + 100 - -# Initialise the Fanout Cache instance -data = FanoutCache('test', shards=8) - - -# Declare the function used to modify the data -def set_data(**kwargs): - """ - - Function used to modify the cache. - - :param kwargs: Key, value pairs of data to modify. - - """ - - # Fetch the global variables - global max_delay - global current_time - global data - - # Update the data with the given keyword arguments - for key, value in kwargs.items(): - - # Measure the time taken to update it for each item - temp = time() - dif = temp - current_time - if dif > max_delay: - max_delay = dif - print("New max delay encountered: ", max_delay) - current_time = temp - - # Update the data - data[key] = value - - -# Initialise some test values -test_values = {str(key): value for key in range(25) for value in range(25)} - -# Keep writing the data in 0.1 sec intervals -while True: - sleep(0.1) - set_data(**test_values) +"""Benchmark for Issue #109 + +""" + +import time +import diskcache as dc + + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--cache-dir', default='/tmp/test') + parser.add_argument('--iterations', type=int, default=100) + parser.add_argument('--sleep', type=float, default=0.1) + parser.add_argument('--size', type=int, default=25) + args = parser.parse_args() + + data = dc.FanoutCache(args.cache_dir) + delays = [] + values = {str(num): num for num in range(args.size)} + iterations = args.iterations + + for i in range(args.iterations): + print(f'Iteration {i + 1}/{iterations}', end='\r') + time.sleep(args.sleep) + for key, value in values.items(): + start = time.monotonic() + data[key] = value + stop = time.monotonic() + diff = stop - start + delays.append(diff) + + # Discard warmup delays, first two iterations. + del delays[:(len(values) * 2)] + + # Convert seconds to microseconds. + delays = sorted(delay * 1e6 for delay in delays) + + # Display performance. + print() + print(f'Total #: {len(delays)}') + print(f'Min delay (us): {delays[0]:>8.3f}') + print(f'50th %ile (us): {delays[int(len(delays) * 0.50)]:>8.3f}') + print(f'90th %ile (us): {delays[int(len(delays) * 0.90)]:>8.3f}') + print(f'99th %ile (us): {delays[int(len(delays) * 0.99)]:>8.3f}') + print(f'Max delay (us): {delays[-1]:>8.3f}') + + +if __name__ == '__main__': + main() From 1e56fd5152aac53d41738d872bc33ecf0c2f357c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 13:56:51 -0700 Subject: [PATCH 107/329] Add pytest-xdist to tox.ini for testenv --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 3f8de58..59c2da7 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ deps= mock pytest pytest-django + pytest-xdist commands=python -m pytest setenv = DJANGO_SETTINGS_MODULE=tests.settings From b482ea1a19f2ad8169962232d59254790acf2ed7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 15:25:52 -0700 Subject: [PATCH 108/329] Change expirations in tests from 0 to 1e-9 --- diskcache/core.py | 3 +++ docs/tutorial.rst | 2 +- tests/test_core.py | 10 +++++----- tests/test_fanout.py | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index deef26c..5fb1f00 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -693,6 +693,9 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. + If `expire` is less than or equal to zero then immediately returns + `False`. + Raises :exc:`Timeout` error when database timeout occurs and `retry` is `False` (default). diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 20aaaaa..8a67ab8 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -195,7 +195,7 @@ Another four methods remove items from the cache. >>> cache.reset('cull_limit', 0) # Disable automatic evictions. 0 >>> for num in range(10): - ... _ = cache.set(num, num, expire=0) # Expire immediately. + ... _ = cache.set(num, num, expire=1e-9) # Expire immediately. >>> len(cache) 10 >>> list(cache) diff --git a/tests/test_core.py b/tests/test_core.py index bcaea05..acf6b3f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -402,7 +402,7 @@ def test_pop(cache): assert cache.set('alpha', 123, expire=1, tag='blue') assert cache.pop('alpha', tag=True) == (123, 'blue') - assert cache.set('beta', 456, expire=0, tag='green') + assert cache.set('beta', 456, expire=1e-9, tag='green') time.sleep(0.01) assert cache.pop('beta', 'dne') == 'dne' @@ -522,7 +522,7 @@ def test_expire_rows(cache): cache.reset('cull_limit', 0) for value in range(10): - assert cache.set(value, value, expire=0) + assert cache.set(value, value, expire=1e-9) for value in range(10, 15): assert cache.set(value, value) @@ -709,12 +709,12 @@ def test_expire(cache): time_time = mock.Mock(return_value=now) with mock.patch('time.time', time_time): - for value in range(100): + for value in range(1, 101): assert cache.set(value, value, expire=value) assert len(cache) == 100 - time_time = mock.Mock(return_value=now + 10) + time_time = mock.Mock(return_value=now + 11) cache.reset('cull_limit', 10) with mock.patch('time.time', time_time): assert cache.expire() == 10 @@ -885,7 +885,7 @@ def test_iter(cache): def test_iter_expire(cache): cache.reset('cull_limit', 0) for num in range(100): - cache.set(num, num, expire=0) + cache.set(num, num, expire=1e-9) assert len(cache) == 100 assert list(cache) == list(range(100)) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 1d96d13..a62f8a2 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -374,7 +374,7 @@ def test_expire(cache): cache.reset('cull_limit', 0) for value in range(100): - cache.set(value, value, expire=0) + cache.set(value, value, expire=1e-9) assert len(cache) == 100 @@ -502,7 +502,7 @@ def test_iter_expire(cache): """ cache.reset('cull_limit', 0) for num in range(100): - cache.set(num, num, expire=0) + cache.set(num, num, expire=1e-9) time.sleep(0.1) assert set(cache) == set(range(100)) cache.expire() From 8051922d19cd80be0f89133efd036bf28071484e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 15:26:21 -0700 Subject: [PATCH 109/329] Addm missing imports for DjangoCache memoize --- diskcache/djangocache.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index fa2e061..6b16a87 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -1,6 +1,8 @@ "Django-compatible disk and file backed cache." from functools import wraps +from math import log +from random import random from time import time from django.core.cache.backends.base import BaseCache @@ -413,8 +415,8 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, if callable(name): raise TypeError('name cannot be callable') - if early_recompute and expire is None: - raise ValueError('expire required') + if early_recompute and timeout is None: + raise ValueError('timeout required') def decorator(func): "Decorator created by memoize call for callable." From bec534c9ad54fe48bd11e0702c7d04f70a06e985 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 15:26:39 -0700 Subject: [PATCH 110/329] Remove unused import --- diskcache/persistent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index ab22e09..623c56c 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -8,7 +8,6 @@ from collections import OrderedDict from contextlib import contextmanager from shutil import rmtree -from tempfile import mkdtemp from .core import BytesType, Cache, ENOVAL, TextType From 9f5232c3faea20d9379eae761fa4450035d7a5d0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 15:59:52 -0700 Subject: [PATCH 111/329] Test fixes for Python 2.7 --- diskcache/memo.py | 4 ++-- tests/test_early_recompute.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/diskcache/memo.py b/diskcache/memo.py index 9d4fc24..2669657 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -92,14 +92,14 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None, ... return 1 ... else: ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> fibonacci(100) + >>> print(fibonacci(100)) 354224848179261915075 An additional `__cache_key__` attribute can be used to generate the cache key used for the given arguments. >>> key = fibonacci.__cache_key__(100) - >>> cache[key] + >>> print(cache[key]) 354224848179261915075 Remember to call memoize when decorating a callable. If you forget, then a diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py index 2ae8ffe..6ffa4e9 100644 --- a/tests/test_early_recompute.py +++ b/tests/test_early_recompute.py @@ -10,10 +10,9 @@ """ -import concurrent.futures import diskcache as dc import functools -import matplotlib.pyplot as plt +import multiprocessing.pool import shutil import threading import time @@ -70,6 +69,8 @@ def frange(start, stop, step=1e-3): def plot(cache_times, worker_times): "Plot concurrent workers and latency." + import matplotlib.pyplot as plt + fig, (workers, latency) = plt.subplots(2, sharex=True) changes = [(start, 1) for start, _ in worker_times] @@ -135,7 +136,7 @@ def plot(cache_times, worker_times): repeater = make_repeater(worker) - with concurrent.futures.ThreadPoolExecutor(count) as executor: - executor.map(repeater, [worker] * count) + with multiprocessing.pool.ThreadPool() as pool: + pool.map(repeater, [worker] * count) plot(cache_times, worker_times) From b40c0c7af5c426ed316a5cb1b0ba47e97376f46a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 16:27:06 -0700 Subject: [PATCH 112/329] Specify ThreadPoolExecutor max workers for Python 3.4 --- diskcache/recipes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index e5da48c..96a9f58 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -9,7 +9,7 @@ ... time.sleep(1) ... return num >>> from concurrent.futures import ThreadPoolExecutor ->>> with ThreadPoolExecutor() as executor: +>>> with ThreadPoolExecutor(5) as executor: ... start = time.time() ... times = list(executor.map(work, range(5))) ... end = time.time() @@ -17,7 +17,7 @@ [0, 1, 2, 3, 4] >>> int(end - start) 5 ->>> with ThreadPoolExecutor() as executor: +>>> with ThreadPoolExecutor(5) as executor: ... start = time.time() ... times = list(executor.map(work, range(5))) ... end = time.time() @@ -303,7 +303,7 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): ... time.sleep(1) ... return int(time.time()) >>> from concurrent.futures import ThreadPoolExecutor - >>> with ThreadPoolExecutor() as executor: + >>> with ThreadPoolExecutor(4) as executor: ... times = sorted(executor.map(work, range(4))) >>> [times[i] - times[i - 1] for i in range(1, 4)] [1, 1, 1] From c250a0243800ca03ad59c4f5610654bb63d02157 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 16:48:13 -0700 Subject: [PATCH 113/329] Change PYTHONPATH for Windows testing --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 59c2da7..0566db9 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ deps= commands=python -m pytest setenv = DJANGO_SETTINGS_MODULE=tests.settings - PYTHONPATH={toxinidir}:{toxinidir}/tests + PYTHONPATH={toxinidir} [pytest] addopts= From 8c77394aa4417cd45aee2dbf5c596938b36f481c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 11:18:20 -0700 Subject: [PATCH 114/329] Add changes for threaded recomputation --- diskcache/memo.py | 24 ++++++++++++++++++------ tests/test_early_recompute.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/diskcache/memo.py b/diskcache/memo.py index 2669657..67f0274 100644 --- a/diskcache/memo.py +++ b/diskcache/memo.py @@ -5,6 +5,7 @@ from functools import wraps from math import log from random import random +from threading import Thread from time import time MARK = object() @@ -144,6 +145,14 @@ def wrapper(*args, **kwargs): key, default=MARK, expire_time=True, retry=True, ) + def recompute(): + start = time_func() + result = func(*args, **kwargs) + delta = time_func() - start + pair = result, delta + cache.set(key, pair, expire=expire, tag=tag, retry=True) + return result + if pair is not MARK: result, delta = pair now = time_func() @@ -151,13 +160,16 @@ def wrapper(*args, **kwargs): if (-delta * early_recompute * log(random())) < ttl: return result + elif True: # Background + # How to support asyncio? + thread_key = key + (MARK,) + if cache.add(thread_key, None, expire=delta): + thread = Thread(target=recompute) + thread.daemon = True + thread.start() + return result - start = time_func() - result = func(*args, **kwargs) - delta = time_func() - start - pair = result, delta - cache.set(key, pair, expire=expire, tag=tag, retry=True) - return result + return recompute() else: @wraps(func) def wrapper(*args, **kwargs): diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py index 6ffa4e9..36f31d5 100644 --- a/tests/test_early_recompute.py +++ b/tests/test_early_recompute.py @@ -69,8 +69,8 @@ def frange(start, stop, step=1e-3): def plot(cache_times, worker_times): "Plot concurrent workers and latency." + # TODO: Update x-axis to normalize to 0 import matplotlib.pyplot as plt - fig, (workers, latency) = plt.subplots(2, sharex=True) changes = [(start, 1) for start, _ in worker_times] @@ -90,12 +90,13 @@ def plot(cache_times, worker_times): pair = (mark, counts[-1][1] + diff) counts.append(pair) + min_x = min(start for start, _ in cache_times) max_x = max(start for start, _ in cache_times) for step in frange(counts[-1][0], max_x): pair = (step, counts[-1][1]) counts.append(pair) - x_counts = [x for x, y in counts] + x_counts = [x - min_x for x, y in counts] y_counts = [y for x, y in counts] workers.set_title('Concurrent Workers') @@ -105,7 +106,7 @@ def plot(cache_times, worker_times): latency.set_title('Latency') latency.set_ylabel('Seconds') latency.set_xlabel('Time') - x_latency = [start for start, _ in cache_times] + x_latency = [start - min_x for start, _ in cache_times] y_latency = [stop - start for start, stop in cache_times] latency.scatter(x_latency, y_latency) @@ -122,15 +123,34 @@ def plot(cache_times, worker_times): count = 16 cache_times = [] - worker_times = [] + timer = make_timer(cache_times) - worker = make_worker(worker_times) decorators = [ - make_timer(cache_times), - cache.memoize(expire=10, early_recompute=1.5), + timer, + + # Option 0: No Caching + + # Option 1: Traditional Caching + # cache.memoize(expire=10), + + # Option 2: Synchronized Locking + # cache.memoize(expire=0), # dc.barrier(cache, dc.Lock), # cache.memoize(expire=10), + + # Option 3: Early Recomputation + # cache.memoize(expire=10, early_recompute=True), + + # Option 4: Early Recomputation Tuning + # cache.memoize(expire=10, early_recompute=1.5), # =0.5), + + # Option 5: Background Early Recomputation + # cache.memoize(expire=10, early_recompute=True, background='threading'), + # TODO: background parameter? or early_recompute='background' ] + + worker_times = [] + worker = make_worker(worker_times) for decorator in reversed(decorators): worker = decorator(worker) From c6703b6d4c4f46aaf67e58b9fb60513232b30034 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 7 Jun 2019 11:18:20 -0700 Subject: [PATCH 115/329] Add changes for threaded recomputation --- tests/test_early_recompute.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py index 36f31d5..5f68c73 100644 --- a/tests/test_early_recompute.py +++ b/tests/test_early_recompute.py @@ -156,7 +156,16 @@ def plot(cache_times, worker_times): repeater = make_repeater(worker) +<<<<<<< HEAD with multiprocessing.pool.ThreadPool() as pool: pool.map(repeater, [worker] * count) +======= + import multiprocessing.pool as mp + with mp.ThreadPool(count) as pool: + list(pool.map(repeater, [worker] * count)) + + # with concurrent.futures.ThreadPoolExecutor(count) as executor: + # executor.map(repeater, [worker] * count) +>>>>>>> Add changes for threaded recomputation plot(cache_times, worker_times) From 5caa8fdb94da3b767d230781ae3f625f4cdbe897 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 11 Jun 2019 09:07:53 -0700 Subject: [PATCH 116/329] Remove early recomputation and move into core.Cache --- diskcache/core.py | 127 +++++++++++++++++++++- diskcache/djangocache.py | 74 +++---------- diskcache/fanout.py | 3 +- diskcache/memo.py | 193 ---------------------------------- diskcache/persistent.py | 13 ++- diskcache/recipes.py | 143 +++++++++++++++++++++++++ tests/test_early_recompute.py | 9 -- 7 files changed, 293 insertions(+), 269 deletions(-) delete mode 100644 diskcache/memo.py diff --git a/diskcache/core.py b/diskcache/core.py index 5fb1f00..5047ef8 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -19,8 +19,6 @@ import warnings import zlib -from .memo import memoize - if sys.hexversion < 0x03000000: import cPickle as pickle # pylint: disable=import-error # ISSUE #25 Fix for http://bugs.python.org/issue10211 @@ -40,6 +38,16 @@ INT_TYPES = (int,) io_open = open # pylint: disable=invalid-name +def full_name(func): + "Return full name of `func` by adding the module and function name." + try: + # The __qualname__ attribute is only available in Python 3.3 and later. + # GrantJ 2019-03-29 Remove after support for Python 2 is dropped. + name = func.__qualname__ + except AttributeError: + name = func.__name__ + return func.__module__ + '.' + name + try: WindowsError except NameError: @@ -357,6 +365,34 @@ class EmptyDirWarning(UserWarning): "Warning used by Cache.check for empty directories." +def args_to_key(base, args, kwargs, typed): + """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 + :return: cache key tuple + + """ + key = base + args + + if kwargs: + key += (ENOVAL,) + sorted_items = sorted(kwargs.items()) + + for item in sorted_items: + key += item + + if typed: + key += tuple(type(arg) for arg in args) + + if kwargs: + key += tuple(type(value) for _, value in sorted_items) + + return key + + class Cache(object): "Disk and file backed cache." # pylint: disable=bad-continuation @@ -1725,7 +1761,92 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): return key, value - memoize = memoize + def memoize(self, name=None, typed=False, expire=None, tag=None): + """Memoizing cache decorator. + + Decorator to wrap callable with memoizing function using cache. + Repeated calls with the same arguments will lookup result in cache and + avoid function evaluation. + + If name is set to None (default), the callable name will be determined + automatically. + + If typed is set to True, function arguments of different types will be + cached separately. For example, f(3) and f(3.0) will be treated as + distinct calls with distinct results. + + The original underlying function is accessible through the __wrapped__ + attribute. This is useful for introspection, for bypassing the cache, + or for rewrapping the function with a different cache. + + >>> from diskcache import Cache + >>> cache = Cache() + >>> @cache.memoize(expire=1, tag='fib') + ... def fibonacci(number): + ... if number == 0: + ... return 0 + ... elif number == 1: + ... return 1 + ... else: + ... return fibonacci(number - 1) + fibonacci(number - 2) + >>> print(fibonacci(100)) + 354224848179261915075 + + An additional `__cache_key__` attribute can be used to generate the + cache key used for the given arguments. + + >>> key = fibonacci.__cache_key__(100) + >>> print(cache[key]) + 354224848179261915075 + + Remember to call memoize when decorating a callable. If you forget, + then a TypeError will occur. Note the lack of parenthenses after + memoize below: + + >>> @cache.memoize + ... def test(): + ... pass + Traceback (most recent call last): + ... + TypeError: name cannot be callable + + :param cache: cache to store callable arguments and return values + :param str name: name given for callable (default None, automatic) + :param bool typed: cache different types separately (default False) + :param float expire: seconds until arguments expire + (default None, no expiry) + :param str tag: text to associate with arguments (default None) + :return: callable decorator + + """ + # Caution: Nearly identical code exists in DjangoCache.memoize + if callable(name): + raise TypeError('name cannot be callable') + + def decorator(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." + key = wrapper.__cache_key__(*args, **kwargs) + result = self.get(key, default=ENOVAL, retry=True) + + if result is ENOVAL: + result = func(*args, **kwargs) + self.set(key, result, expire=expire, tag=tag, retry=True) + + return result + + def __cache_key__(*args, **kwargs): + "Make key for cache given function arguments." + return args_to_key(base, args, kwargs, typed) + + wrapper.__cache_key__ = __cache_key__ + return wrapper + + return decorator def check(self, fix=False, retry=False): diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 6b16a87..995fb95 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -12,8 +12,8 @@ # For older versions of Django simply use 300 seconds. DEFAULT_TIMEOUT = 300 +from .core import ENOVAL, args_to_key, full_name from .fanout import FanoutCache -from .memo import MARK, _args_to_key, full_name class DjangoCache(BaseCache): @@ -361,7 +361,7 @@ def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT): def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, - typed=False, tag=None, early_recompute=False, time_func=time): + typed=False, tag=None): """Memoizing cache decorator. Decorator to wrap callable with memoizing function using cache. @@ -375,20 +375,6 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. - Cache stampedes are a type of cascading failure that can occur when - parallel computing systems using memoization come under heavy - load. This behaviour is sometimes also called dog-piling, cache miss - storm, cache choking, or the thundering herd problem. - - The memoization decorator includes cache stampede protection through - the early recomputation parameter. When set to True (default False), - the expire parameter must not be None. Early recomputation of results - will occur probabilistically before expiration. - - Early probabilistic recomputation is based on research by Vattani, A.; - Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache - Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 - The original underlying function is accessible through the __wrapped__ attribute. This is useful for introspection, for bypassing the cache, or for rewrapping the function with a different cache. @@ -405,60 +391,30 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, :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 bool early_recompute: probabilistic early recomputation - (default False) - :param time_func: callable for calculating current time :return: callable decorator """ - # Caution: Nearly identical code exists in memo.memoize + # Caution: Nearly identical code exists in Cache.memoize if callable(name): raise TypeError('name cannot be callable') - if early_recompute and timeout is None: - raise ValueError('timeout required') - def decorator(func): - "Decorator created by memoize call for callable." + "Decorator created by memoize() for callable `func`." base = (full_name(func),) if name is None else (name,) - if early_recompute: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - pair, expire_time = self.get( - key, MARK, version, expire_time=True, retry=True, - ) - - if pair is not MARK: - result, delta = pair - now = time_func() - ttl = expire_time - now - - if (-delta * log(random())) < ttl: - return result + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + result = self.get(key, ENOVAL, version, retry=True) - start = time_func() + if result is ENOVAL: result = func(*args, **kwargs) - delta = time_func() - start - pair = result, delta - self.set(key, pair, timeout, version, tag=tag, retry=True) - return result - else: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - result = self.get(key, MARK, version, retry=True) - - if result is MARK: - result = func(*args, **kwargs) - self.set( - key, result, timeout, version, tag=tag, retry=True, - ) - - return result + self.set( + key, result, timeout, version, tag=tag, retry=True, + ) + + return result def __cache_key__(*args, **kwargs): "Make key for cache given function arguments." diff --git a/diskcache/fanout.py b/diskcache/fanout.py index e89f0f6..dc31593 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -13,7 +13,6 @@ reduce # pylint: disable=pointless-statement from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout -from .memo import memoize from .persistent import Deque, Index @@ -356,7 +355,7 @@ def __delitem__(self, key): del shard[key] - memoize = memoize + memoize = Cache.memoize def check(self, fix=False, retry=False): diff --git a/diskcache/memo.py b/diskcache/memo.py deleted file mode 100644 index 67f0274..0000000 --- a/diskcache/memo.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Memoization utilities. - -""" - -from functools import wraps -from math import log -from random import random -from threading import Thread -from time import time - -MARK = object() - - -def full_name(func): - "Return full name of `func` by adding the module and function name." - try: - # The __qualname__ attribute is only available in Python 3.3 and later. - # GrantJ 2019-03-29 Remove after support for Python 2 is dropped. - name = func.__qualname__ - except AttributeError: - name = func.__name__ - return func.__module__ + '.' + name - - -def _args_to_key(base, args, kwargs, typed): - """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 - :return: cache key tuple - - """ - key = base + args - - if kwargs: - key += (MARK,) - sorted_items = sorted(kwargs.items()) - - for item in sorted_items: - key += item - - if typed: - key += tuple(type(arg) for arg in args) - - if kwargs: - key += tuple(type(value) for _, value in sorted_items) - - return key - - -def memoize(cache, name=None, typed=False, expire=None, tag=None, - early_recompute=False, time_func=time): - """Memoizing cache decorator. - - Decorator to wrap callable with memoizing function using cache. Repeated - calls with the same arguments will lookup result in cache and avoid - function evaluation. - - If name is set to None (default), the callable name will be determined - automatically. - - If typed is set to True, function arguments of different types will be - cached separately. For example, f(3) and f(3.0) will be treated as distinct - calls with distinct results. - - Cache stampedes are a type of cascading failure that can occur when - parallel computing systems using memoization come under heavy load. This - behaviour is sometimes also called dog-piling, cache miss storm, cache - choking, or the thundering herd problem. - - The memoization decorator includes cache stampede protection through the - early recomputation parameter. When set to True (default False), the expire - parameter must not be None. Early recomputation of results will occur - probabilistically before expiration. - - Early probabilistic recomputation is based on research by Vattani, A.; - Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache - Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 - - The original underlying function is accessible through the __wrapped__ - attribute. This is useful for introspection, for bypassing the cache, or - for rewrapping the function with a different cache. - - >>> from diskcache import FanoutCache - >>> cache = FanoutCache() - >>> @cache.memoize(typed=True, expire=1, tag='fib') - ... def fibonacci(number): - ... if number == 0: - ... return 0 - ... elif number == 1: - ... return 1 - ... else: - ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> print(fibonacci(100)) - 354224848179261915075 - - An additional `__cache_key__` attribute can be used to generate the cache key - used for the given arguments. - - >>> key = fibonacci.__cache_key__(100) - >>> print(cache[key]) - 354224848179261915075 - - Remember to call memoize when decorating a callable. If you forget, then a - TypeError will occur. Note the lack of parenthenses after memoize below: - - >>> @cache.memoize - ... def test(): - ... pass - Traceback (most recent call last): - ... - TypeError: name cannot be callable - - :param cache: cache to store callable arguments and return values - :param str name: name given for callable (default None, automatic) - :param bool typed: cache different types separately (default False) - :param float expire: seconds until arguments expire - (default None, no expiry) - :param str tag: text to associate with arguments (default None) - :param bool early_recompute: probabilistic early recomputation - (default False) - :param time_func: callable for calculating current time - :return: callable decorator - - """ - # Caution: Nearly identical code exists in DjangoCache.memoize - if callable(name): - raise TypeError('name cannot be callable') - - if early_recompute and expire is None: - raise ValueError('expire required') - - def decorator(func): - "Decorator created by memoize call for callable." - base = (full_name(func),) if name is None else (name,) - - if early_recompute: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - pair, expire_time = cache.get( - key, default=MARK, expire_time=True, retry=True, - ) - - def recompute(): - start = time_func() - result = func(*args, **kwargs) - delta = time_func() - start - pair = result, delta - cache.set(key, pair, expire=expire, tag=tag, retry=True) - return result - - if pair is not MARK: - result, delta = pair - now = time_func() - ttl = expire_time - now - - if (-delta * early_recompute * log(random())) < ttl: - return result - elif True: # Background - # How to support asyncio? - thread_key = key + (MARK,) - if cache.add(thread_key, None, expire=delta): - thread = Thread(target=recompute) - thread.daemon = True - thread.start() - return result - - return recompute() - else: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - result = cache.get(key, default=MARK, retry=True) - - if result is MARK: - result = func(*args, **kwargs) - cache.set(key, result, expire=expire, tag=tag, retry=True) - - return result - - def __cache_key__(*args, **kwargs): - "Make key for cache given function arguments." - return _args_to_key(base, args, kwargs, typed) - - wrapper.__cache_key__ = __cache_key__ - return wrapper - - return decorator diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 623c56c..a8b5946 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1326,7 +1326,7 @@ def memoize(self, name=None, typed=False): >>> from diskcache import Index >>> mapping = Index() - >>> @mapping.memoize(typed=True) + >>> @mapping.memoize() ... def fibonacci(number): ... if number == 0: ... return 0 @@ -1334,8 +1334,15 @@ def memoize(self, name=None, typed=False): ... return 1 ... else: ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> print(sum(fibonacci(number=value) for value in range(100))) - 573147844013817084100 + >>> print(fibonacci(100)) + 354224848179261915075 + + An additional `__cache_key__` attribute can be used to generate the + cache key used for the given arguments. + + >>> key = fibonacci.__cache_key__(100) + >>> print(cache[key]) + 354224848179261915075 Remember to call memoize when decorating a callable. If you forget, then a TypeError will occur. Note the lack of parenthenses after diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 96a9f58..6ce2d1b 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -321,3 +321,146 @@ def wrapper(*args, **kwargs): return wrapper return decorator + + +def memoize(cache, name=None, typed=False, expire=None, tag=None, + early_recompute=False, time_func=time): + """Memoizing cache decorator. + + Decorator to wrap callable with memoizing function using cache. Repeated + calls with the same arguments will lookup result in cache and avoid + function evaluation. + + If name is set to None (default), the callable name will be determined + automatically. + + If typed is set to True, function arguments of different types will be + cached separately. For example, f(3) and f(3.0) will be treated as distinct + calls with distinct results. + + Cache stampedes are a type of cascading failure that can occur when + parallel computing systems using memoization come under heavy load. This + behaviour is sometimes also called dog-piling, cache miss storm, cache + choking, or the thundering herd problem. + + The memoization decorator includes cache stampede protection through the + early recomputation parameter. When set to True (default False), the expire + parameter must not be None. Early recomputation of results will occur + probabilistically before expiration. + + Early probabilistic recomputation is based on research by Vattani, A.; + Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache + Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 + + The original underlying function is accessible through the __wrapped__ + attribute. This is useful for introspection, for bypassing the cache, or + for rewrapping the function with a different cache. + + >>> from diskcache import FanoutCache + >>> cache = FanoutCache() + >>> @cache.memoize(typed=True, expire=1, tag='fib') + ... def fibonacci(number): + ... if number == 0: + ... return 0 + ... elif number == 1: + ... return 1 + ... else: + ... return fibonacci(number - 1) + fibonacci(number - 2) + >>> print(fibonacci(100)) + 354224848179261915075 + + An additional `__cache_key__` attribute can be used to generate the cache key + used for the given arguments. + + >>> key = fibonacci.__cache_key__(100) + >>> print(cache[key]) + 354224848179261915075 + + Remember to call memoize when decorating a callable. If you forget, then a + TypeError will occur. Note the lack of parenthenses after memoize below: + + >>> @cache.memoize + ... def test(): + ... pass + Traceback (most recent call last): + ... + TypeError: name cannot be callable + + :param cache: cache to store callable arguments and return values + :param str name: name given for callable (default None, automatic) + :param bool typed: cache different types separately (default False) + :param float expire: seconds until arguments expire + (default None, no expiry) + :param str tag: text to associate with arguments (default None) + :param bool early_recompute: probabilistic early recomputation + (default False) + :param time_func: callable for calculating current time + :return: callable decorator + + """ + # Caution: Nearly identical code exists in DjangoCache.memoize + if callable(name): + raise TypeError('name cannot be callable') + + if early_recompute and expire is None: + raise ValueError('expire required') + + def decorator(func): + "Decorator created by memoize call for callable." + base = (full_name(func),) if name is None else (name,) + + if early_recompute: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + pair, expire_time = cache.get( + key, default=MARK, expire_time=True, retry=True, + ) + + def recompute(): + start = time_func() + result = func(*args, **kwargs) + delta = time_func() - start + pair = result, delta + cache.set(key, pair, expire=expire, tag=tag, retry=True) + return result + + if pair is not MARK: + result, delta = pair + now = time_func() + ttl = expire_time - now + + if (-delta * early_recompute * log(random())) < ttl: + return result + elif True: # Background + # How to support asyncio? + thread_key = key + (MARK,) + if cache.add(thread_key, None, expire=delta): + thread = Thread(target=recompute) + thread.daemon = True + thread.start() + return result + + return recompute() + else: + @wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + result = cache.get(key, default=MARK, retry=True) + + if result is MARK: + result = func(*args, **kwargs) + cache.set(key, result, expire=expire, tag=tag, retry=True) + + return result + + def __cache_key__(*args, **kwargs): + "Make key for cache given function arguments." + return _args_to_key(base, args, kwargs, typed) + + wrapper.__cache_key__ = __cache_key__ + return wrapper + + return decorator diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py index 5f68c73..36f31d5 100644 --- a/tests/test_early_recompute.py +++ b/tests/test_early_recompute.py @@ -156,16 +156,7 @@ def plot(cache_times, worker_times): repeater = make_repeater(worker) -<<<<<<< HEAD with multiprocessing.pool.ThreadPool() as pool: pool.map(repeater, [worker] * count) -======= - import multiprocessing.pool as mp - with mp.ThreadPool(count) as pool: - list(pool.map(repeater, [worker] * count)) - - # with concurrent.futures.ThreadPoolExecutor(count) as executor: - # executor.map(repeater, [worker] * count) ->>>>>>> Add changes for threaded recomputation plot(cache_times, worker_times) From 1f9731c4bbe1bf05e522dfb27923fdf9d5136542 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 11 Jun 2019 10:33:43 -0700 Subject: [PATCH 117/329] Fixes for memoizing --- diskcache/__init__.py | 9 +-- diskcache/core.py | 2 +- diskcache/djangocache.py | 2 +- diskcache/persistent.py | 2 +- diskcache/recipes.py | 168 ++++++++++++++++----------------------- tests/test_doctest.py | 6 -- 6 files changed, 76 insertions(+), 113 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 17cbeb3..dba3cde 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -1,11 +1,11 @@ "DiskCache: disk and file backed cache." -from .core import Cache, Disk, UnknownFileWarning, EmptyDirWarning, Timeout +from .core import Cache, Disk, EmptyDirWarning, UnknownFileWarning, Timeout from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache -from .memo import memoize from .persistent import Deque, Index -from .recipes import Averager, Lock, RLock, BoundedSemaphore, throttle, barrier +from .recipes import Averager, BoundedSemaphore, Lock, RLock +from .recipes import barrier, memoize_stampede, throttle __all__ = [ 'Averager', @@ -25,7 +25,7 @@ 'UNKNOWN', 'UnknownFileWarning', 'barrier', - 'memoize', + 'memoize_stampede', 'throttle', ] @@ -36,7 +36,6 @@ # Django not installed or not setup so ignore. pass - __title__ = 'diskcache' __version__ = '3.1.1' __build__ = 0x030101 diff --git a/diskcache/core.py b/diskcache/core.py index 5047ef8..10b8e0b 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1827,7 +1827,7 @@ def decorator(func): "Decorator created by memoize() for callable `func`." base = (full_name(func),) if name is None else (name,) - @wraps(func) + @ft.wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." key = wrapper.__cache_key__(*args, **kwargs) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 995fb95..fbafeaa 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -418,7 +418,7 @@ def wrapper(*args, **kwargs): def __cache_key__(*args, **kwargs): "Make key for cache given function arguments." - return _args_to_key(base, args, kwargs, typed) + return args_to_key(base, args, kwargs, typed) wrapper.__cache_key__ = __cache_key__ return wrapper diff --git a/diskcache/persistent.py b/diskcache/persistent.py index a8b5946..5f2dbde 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1341,7 +1341,7 @@ def memoize(self, name=None, typed=False): cache key used for the given arguments. >>> key = fibonacci.__cache_key__(100) - >>> print(cache[key]) + >>> print(mapping[key]) 354224848179261915075 Remember to call memoize when decorating a callable. If you forget, diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 6ce2d1b..04bff6a 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -2,26 +2,26 @@ >>> import diskcache as dc, time >>> cache = dc.Cache() ->>> @dc.memoize(cache) -... @dc.barrier(cache, dc.Lock) -... @dc.memoize(cache) +>>> @cache.memoize(expire=0) +... @barrier(cache, dc.Lock) +... @cache.memoize() ... def work(num): ... time.sleep(1) ... return num >>> from concurrent.futures import ThreadPoolExecutor >>> with ThreadPoolExecutor(5) as executor: ... start = time.time() -... times = list(executor.map(work, range(5))) +... nums = list(executor.map(work, range(5))) ... end = time.time() ->>> times +>>> nums [0, 1, 2, 3, 4] >>> int(end - start) 5 >>> with ThreadPoolExecutor(5) as executor: ... start = time.time() -... times = list(executor.map(work, range(5))) +... nums = list(executor.map(work, range(5))) ... end = time.time() ->>> times +>>> nums [0, 1, 2, 3, 4] >>> int(end - start) 0 @@ -29,11 +29,13 @@ """ import functools +import math import os +import random import threading import time -from .memo import full_name +from .core import ENOVAL, args_to_key, full_name class Averager(object): @@ -234,7 +236,7 @@ def __exit__(self, *exc_info): def throttle(cache, count, seconds, name=None, expire=None, tag=None, - time_func=time.time, sleep_func=time.sleep): + time_func=time.monotonic, sleep_func=time.sleep): """Decorator to throttle calls to function. >>> import diskcache, time @@ -323,13 +325,21 @@ def wrapper(*args, **kwargs): return decorator -def memoize(cache, name=None, typed=False, expire=None, tag=None, - early_recompute=False, time_func=time): - """Memoizing cache decorator. +def memoize_stampede(cache, expire, name=None, typed=False, tag=None, + time_func=time.monotonic): + """Memoizing cache decorator with cache stampede protection. - Decorator to wrap callable with memoizing function using cache. Repeated - calls with the same arguments will lookup result in cache and avoid - function evaluation. + Cache stampedes are a type of cascading failure that can occur when + parallel computing systems using memoization come under heavy load. This + behaviour is sometimes also called dog-piling, cache miss storm, cache + choking, or the thundering herd problem. + + The memoization decorator implements cache stampede protection through + early recomputation. Early recomputation of function results will occur + probabilistically before expiration in a background thread of + execution. Early probabilistic recomputation is based on research by + Vattani, A.; Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic + Cache Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 If name is set to None (default), the callable name will be determined automatically. @@ -338,27 +348,13 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None, cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. - Cache stampedes are a type of cascading failure that can occur when - parallel computing systems using memoization come under heavy load. This - behaviour is sometimes also called dog-piling, cache miss storm, cache - choking, or the thundering herd problem. - - The memoization decorator includes cache stampede protection through the - early recomputation parameter. When set to True (default False), the expire - parameter must not be None. Early recomputation of results will occur - probabilistically before expiration. - - Early probabilistic recomputation is based on research by Vattani, A.; - Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic Cache - Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 - The original underlying function is accessible through the __wrapped__ attribute. This is useful for introspection, for bypassing the cache, or for rewrapping the function with a different cache. - >>> from diskcache import FanoutCache - >>> cache = FanoutCache() - >>> @cache.memoize(typed=True, expire=1, tag='fib') + >>> from diskcache import Cache + >>> cache = Cache() + >>> @memoize_stampede(cache, expire=1) ... def fibonacci(number): ... if number == 0: ... return 0 @@ -369,96 +365,70 @@ def memoize(cache, name=None, typed=False, expire=None, tag=None, >>> print(fibonacci(100)) 354224848179261915075 - An additional `__cache_key__` attribute can be used to generate the cache key - used for the given arguments. + An additional `__cache_key__` attribute can be used to generate the cache + key used for the given arguments. >>> key = fibonacci.__cache_key__(100) - >>> print(cache[key]) - 354224848179261915075 + >>> del cache[key] Remember to call memoize when decorating a callable. If you forget, then a - TypeError will occur. Note the lack of parenthenses after memoize below: - - >>> @cache.memoize - ... def test(): - ... pass - Traceback (most recent call last): - ... - TypeError: name cannot be callable + TypeError will occur. :param cache: cache to store callable arguments and return values + :param float expire: seconds until arguments expire :param str name: name given for callable (default None, automatic) :param bool typed: cache different types separately (default False) - :param float expire: seconds until arguments expire - (default None, no expiry) :param str tag: text to associate with arguments (default None) - :param bool early_recompute: probabilistic early recomputation - (default False) :param time_func: callable for calculating current time :return: callable decorator """ - # Caution: Nearly identical code exists in DjangoCache.memoize - if callable(name): - raise TypeError('name cannot be callable') - - if early_recompute and expire is None: - raise ValueError('expire required') - + # Caution: Nearly identical code exists in Cache.memoize def decorator(func): "Decorator created by memoize call for callable." base = (full_name(func),) if name is None else (name,) - if early_recompute: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - pair, expire_time = cache.get( - key, default=MARK, expire_time=True, retry=True, - ) - - def recompute(): - start = time_func() - result = func(*args, **kwargs) - delta = time_func() - start - pair = result, delta - cache.set(key, pair, expire=expire, tag=tag, retry=True) - return result - - if pair is not MARK: - result, delta = pair - now = time_func() - ttl = expire_time - now - - if (-delta * early_recompute * log(random())) < ttl: - return result - elif True: # Background - # How to support asyncio? - thread_key = key + (MARK,) - if cache.add(thread_key, None, expire=delta): - thread = Thread(target=recompute) - thread.daemon = True - thread.start() - return result - - return recompute() - else: - @wraps(func) - def wrapper(*args, **kwargs): - "Wrapper for callable to cache arguments and return values." - key = wrapper.__cache_key__(*args, **kwargs) - result = cache.get(key, default=MARK, retry=True) + @functools.wraps(func) + def wrapper(*args, **kwargs): + "Wrapper for callable to cache arguments and return values." + key = wrapper.__cache_key__(*args, **kwargs) + pair, expire_time = cache.get( + key, default=ENOVAL, expire_time=True, retry=True, + ) + + def recompute(): + start = time_func() + result = func(*args, **kwargs) + delta = time_func() - start + pair = result, delta + cache.set(key, pair, expire=expire, tag=tag, retry=True) + return result + + if pair is not ENOVAL: + result, delta = pair + now = time_func() + ttl = expire_time - now - if result is MARK: - result = func(*args, **kwargs) - cache.set(key, result, expire=expire, tag=tag, retry=True) + if (-delta * math.log(random.random())) < ttl: + return result # Cache hit. + + # Check whether a thread has started for early recomputation. + + thread_key = key + (ENOVAL,) + + if cache.add(thread_key, None, expire=delta): + # Start thread for early recomputation. + thread = threading.Thread(target=recompute) + thread.daemon = True + thread.start() return result + return recompute() # Cache miss. + def __cache_key__(*args, **kwargs): "Make key for cache given function arguments." - return _args_to_key(base, args, kwargs, typed) + return args_to_key(base, args, kwargs, typed) wrapper.__cache_key__ = __cache_key__ return wrapper diff --git a/tests/test_doctest.py b/tests/test_doctest.py index d171e48..e398391 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -5,7 +5,6 @@ import diskcache.core import diskcache.djangocache import diskcache.fanout -import diskcache.memo import diskcache.persistent import diskcache.recipes @@ -25,11 +24,6 @@ def test_fanout(): assert failures == 0 -def test_memo(): - failures, _ = doctest.testmod(diskcache.memo) - assert failures == 0 - - def test_persistent(): failures, _ = doctest.testmod(diskcache.persistent) assert failures == 0 From 0e63f94ff7f6f322eac7eca331605d7966ae6f01 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 11 Jun 2019 12:32:41 -0700 Subject: [PATCH 118/329] Add fixes and graphs for landing page caching case study --- README.rst | 2 +- diskcache/recipes.py | 15 ++- docs/_static/early-recomputation-03.png | Bin 0 -> 28276 bytes docs/_static/early-recomputation-05.png | Bin 0 -> 25410 bytes docs/_static/early-recomputation.png | Bin 0 -> 25889 bytes docs/_static/no-caching.png | Bin 0 -> 29528 bytes docs/_static/synchronized-locking.png | Bin 0 -> 26431 bytes docs/_static/traditional-caching.png | Bin 0 -> 28854 bytes docs/case-study-landing-page-caching.rst | 17 ++++ docs/conf.py | 2 +- docs/index.rst | 1 + tests/test_early_recompute.py | 116 ++++++++++++----------- 12 files changed, 88 insertions(+), 65 deletions(-) create mode 100644 docs/_static/early-recomputation-03.png create mode 100644 docs/_static/early-recomputation-05.png create mode 100644 docs/_static/early-recomputation.png create mode 100644 docs/_static/no-caching.png create mode 100644 docs/_static/synchronized-locking.png create mode 100644 docs/_static/traditional-caching.png create mode 100644 docs/case-study-landing-page-caching.rst diff --git a/README.rst b/README.rst index eae044c..ae0b086 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ DiskCache: Disk Backed Cache `DiskCache`_ is an Apache2 licensed disk and file backed cache library, written in pure-Python, and compatible with Django. -The cloud-based computing of 2018 puts a premium on memory. Gigabytes of empty +The cloud-based computing of 2019 puts a premium on memory. Gigabytes of empty space is left on disks as processes vie for memory. Among these processes is Memcached (and sometimes Redis) which is used as a cache. Wouldn't it be nice to leverage empty disk space for caching? diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 04bff6a..8ca2c09 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -325,8 +325,7 @@ def wrapper(*args, **kwargs): return decorator -def memoize_stampede(cache, expire, name=None, typed=False, tag=None, - time_func=time.monotonic): +def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1): """Memoizing cache decorator with cache stampede protection. Cache stampedes are a type of cascading failure that can occur when @@ -379,7 +378,6 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, :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 time_func: callable for calculating current time :return: callable decorator """ @@ -397,26 +395,27 @@ def wrapper(*args, **kwargs): ) def recompute(): - start = time_func() + start = time.time() result = func(*args, **kwargs) - delta = time_func() - start + delta = time.time() - start pair = result, delta cache.set(key, pair, expire=expire, tag=tag, retry=True) return result if pair is not ENOVAL: result, delta = pair - now = time_func() + now = time.time() ttl = expire_time - now - if (-delta * math.log(random.random())) < ttl: + if (-delta * beta * math.log(random.random())) < ttl: return result # Cache hit. # Check whether a thread has started for early recomputation. thread_key = key + (ENOVAL,) + thread_added = cache.add(thread_key, None, expire=delta) - if cache.add(thread_key, None, expire=delta): + if thread_added: # Start thread for early recomputation. thread = threading.Thread(target=recompute) thread.daemon = True diff --git a/docs/_static/early-recomputation-03.png b/docs/_static/early-recomputation-03.png new file mode 100644 index 0000000000000000000000000000000000000000..2c75aff77f49e5404c19f2fa21265faf1b582112 GIT binary patch literal 28276 zcmbrmbyQVt_cpp{K_n%l8|jpm5@{r)kp^i20YRixQUnA2fYpy7r8!8tFXb2DlxuB-1q=z7wAqavo za}F1Nb7f%S7yJ*~OHob#9Q*{Fvx|g(<9n!@cp(U}HToY$o?NaI{8G~Uy0Q07_uJmS z)_3g@H*0SXS9fn$CmW`F_IJIU-0v`n@C)*b@G?1idwWO;2>h=%@Vno25O{OG;4OkM zA!eXGD9@S!W`rkbT$1TOYkUy%k=`J2`Q2Xs10Lc&pMfIa%L4>~-?jNAs01 zj!G#O+ zh%(!DXLkR|k3QGnzxd>2V^YS49X685(nwa9sL!%~XlSUTR*Bh%;!9av=|Z+`83qN0 zHNNW%vGmg6hugndB_%I$-bj^~mk+6JsZjl%qjjw@=%oLX?|QpGy6X66&q82(u<`I* zx{r28EVW2}&W)AZpYKhTeEhQ|!f4UI*s?J;G!z@&@%H}q0>#H-mHFSlS*4_CYHMo` zW*!sIEG|ZxRNQ737AAw+dl(lN_j#XD%KKbTinxlVCZUXsjK}gI7q4mMIk~fAkytuO zOmT7XvOkfetb#$uer#-PO)V{Nv*c*vuUlcNie*38Uvq>7gz=hX>t)u|Q1|xsQj{MP z(Mx4mHU`bE;9j7aDf%$gygwZrw?FluL~&T{ucV~pE4N9F7rY8bdw+D_DqpB*)fs3w zT%b*r_9Z&nUsowGEJ7yEjxG=rw?DlkA_$xOczwFm`tfjOwcB_WTz3@i*v7`@%TnOc zUhCq(+p;^8Qgxecv^ghCN|aAthD0%Jwo+MUn18yrvAOv$I{G{d3rlIxiGR_(~OW5p*z2O-+=v%RyrjP*Cp{7fhAe3Pu0)IR$Pl9LPE|{@jLd6e{g5~ z=(B<=ARtil@goatUGV8)uHh$_kz(t`{>*cPgrR!*N~)^oruclNyYXApMV_7< zwTzF`78e)${awo4-0qWe;Z+HIc5>$P*GKHuG9U^Yk<$oSfru;iVURW;p{3-?BAeQx3q=OFe!nhK7eL=IdvHsY-av zgbe@oADsN;Tjn+{=Djvy;!bNjPEvFBDR?eMd}e)}I9o5OEr!;1bGCK%_}4Exp-mcL zyU2sXm;B}g$Y_NF@!$RRIMXU;g?xRM%a_m1wZ*i&k@kHWgv&KtVi{*xWEv%e$hU-F z7|6fLS!n)guk1zsl>6PgcSUoK4i7V(zUPeA-QTut*RXRl#$ZcHNJ)wA?$(TPA_!BQ zyZh~>Wl)qwy}$KL)5FXB{B7Wn190=lJKwdzN+Q}G1fQMw!bK_HD#s{RIS+YraOmpl zo+BVo)YBvTyR-CGNPvqAKQlAaalG2CRxVn~B;T~^y`d)Dt%{NoHpB=*X=!O*lX7f& z>3d(&u0H7h=;MOy3#?b&nWO=;rj`wmpb>M8MC}3s&t+cT(8NUYZ{NQ47n@68q@=Xz zP8I@-{9JDTwZFhfpwP0>Vr83+gQK~%6^ESj#uo?xLJqxj5>isly}d+ZV`C5%2~JOs z+KV-kY6lhOz zZ-^Q$GK+qxe(l;d3}I6c1ql;#Qntikxh9eP}1hNr2ibamUk(lmdJ zif=`MgV=tFBt=bTwW4q1n})zo2#_A$-h)TA5DP+QXKiG52X9LK)))(Sa&kibq4nMP z$Ox6$hdZtPnb+c9zC=H?!s z9PQgeysPow<%V12Y`dwi9|{YgJqg5+heJgsN^%&b?`b+*DfU?zK7b(Ud9>&D_N5Ds zLQ|312OT}VM_#{st!KYPm45gza)0}VlF}FQT`Kl+Da`l_#^rWVi2Uz3z5GE(Gh^et z&5t!T#^;EMsif|$<_Xb@AoG?XZI4Vom0=@iD|J`ML2vQXK+(~T?1o-%* zX%E&UJxyI*3(@bPlkkvtd9%C^slgCoS{q&;r<#C|RAwMBr%vOR;oCgP2OqUJ(;sg=pn|}$@x#nM; ziD4>@-zFv|LKPn4I#mVk`0}26RO-8Kff2HCvOgXD9o=ArR@GQEo_u(g;dl-1=`B$%A(X7WsMUmFk)qVNnHPeKF5mIW`Nmk^u z^dmefD(aD9CJ96}-Wwu2ANCjHOkqb6c`%dr!%_>qZ+H-r8|kucRy>owb8Q!5=0^%m zZW>qIj_Iw2Y19dn5&A8jy^bBU3Go_;zbq& zd*dqSq~R?VR#s9<%G=)cWo2cmnwnq!?yd-VFX|voudleEwliY!Kud2m&hT15U|?Cm z;oTSwJIJG-YrTGNOrM=neYi9E&2buH7q4Xl9d{A|ve7~;Hxou>(Lcti7s#~>5lUfY zxM;Imq%U%NVscU_;K1Wcd;1(51x-@z=%{hbvH<{@f=vc zz`k(s;XD;KHug-Ckp*e4q?8mIhy3P~j7nWb_;*&vZl;P`H6by}MIW1Quksu1uI~uoar15#b&YjC!5{nA&Gzk!5<&o-34RI$4fIZ5Z|>4g-`eX zFrC9ZJ^al$_})O{)`ENE$!!mlI6X^cL+&Kg?ZH6T>|4%zdwXs0LO8&g+4SJk&yXjs zg!gc1)L#`55&8N1w^6ZQR3Rz3d^s05s+d1{hql~FXq?}4_8hI0QvY4&(J`lcb60)W=n$*^ z3GWOM<>O!B#rdxE4V+VDrPoVey@MLRpYbIeZ3DS$q(P{;n9dzpbeITXSbSAJH*WS%Ge87drTm_bf zhHo^8K6&zF1Mu10N=f63<5P&ek$4qT^$*^bmXbgoZV~B|VdXGhUY`1$9$f4@O62S7 z3$CqZ;eLOypnoZ)SRhJBX&U2WWu^X>uyPzj47P~!z7}(H_PCUto$a?)`}7IPg%;5(1kgU`O#99P%!)>v{q*x|n+|EU>wQJZoEKn=uJYe=rMm0>?nGJaIK%#D`W z>g4Ow7|7prF9^Rti;3vz>Gi);BQHACtImvwz=QaHVU0E9v@aL}X6n@klwV))U4zsh zVLp;Qh35Z`XUw=GZ*y)0{aIZ_KS(Fzr&G|=5O}1Md*e+zL&-Yi0x9xu5Rml?47Dbd zmF~7%TSaz3){9Wg?9bychw6vT7tgT3@B#pw_9qlP&z?UIy)*gos%{mtsOUu$TmugD z++NT^_SfwQFI)gn9gta3lK7YF+`W7E7AHT6qmg}^sX&$liiPdb#^A;_!7Dg8IAGe$ zj~+i}-wo!CtADURyYaJSc4cr--*u|)5_~Nw&usu%9X&m*ko3RY6TdUjFWDWNl9JME zHvo~j;F}nrpd#)p|57XSuYQAooa0i&-EDeaUzx2;NFeKeC4vVvAS5HvkAz%+?Pq@d zdbhogOFbP95oU4xg8-a*#x{1wYNGH?V9@S*)~eV=Yz)hw!|dU$!9XPY`1w$Yr5u`H z=e*J+y>J`PPCrS=#Qj+Mp6fZ^MX~9lpnySe{(P0qSgAEuc}0cKJuFG@#eUn#7;U>E z6n2*TtQeND+%$x!2b)d5vp9eS7NG>ee`C3zgyrg0dVm<$A>Cggi*2W%rR5=7I37@8 zyt=u?p_v>4@xlgNI9WDOYISvW3=Fa0OwNSXmTzD){1!Md~4qs8yJ#3=BCz}B52PQ1G^GNKv~a@6^4@6Wi9 z*McTCE-nij8@AhzN(IRKXu|Wmf1jC`mk?Im3h${(;~%KI?=?C;J_kYg`kg!c;G8H} z+U9r&5YG1VBOGcOKS2}(&dgW=;$?u+iSu?}np-)iri8TgTPRCL8v>@T&x z1~rP!@xf-zy+1UtZLPnSb>qI{kswn+M{Z~_d)0rZ>0op2Cse<$L>$n1XT9O5Sb1DI zh6YMuJ-WA>5DFG2YDI7fsYAdWQ2>rd%e=XGtRdD)MNnffJ*AV>`~7#LLr~BeDm+CvA{;0p zfA+qSPOvc|E-Qz^_09I%tSoGZE-}ov(fs){`=tdLlx^7AdR&9W=4O6}^Do1o-f*nj z-&YF#(nS5?-y^b&u;U~U%!$){YSSio2=rz=*{N8_l7)U_$)A@AIt;pJzlFyWFF#o0s z3aF{c76z=X00~0b^JQL2QW9V5wdeqi;`OV9-Dn2ct=;(}9`~ch^DPQjd5nr3>n)eX zG&MBHm1F6+uyJtkAs#CN|IrU1${Nxn%^xuUk)dH>_(ViRG{HQa+G!W1eAjquK7Be5 z`$-Om#X)z~Uw^$+D30~Y6@$f*lJuFSB`);!s}J@r!d~YN5rwp^1G2jY6w{`@(Updg7r-3DgE(VDbZ zgstvH0vuSXNinUhs{t@a&CSglTVDlI-X*xMm{vMI1bU|X^_8y^nIr}@dl+o*mE?{$ zu!Mz$MWuSllU~->Ujn#u%Hy~nU z0?aWD9EzlnXUXdNLcpZUo-FRpfYJ;Hhlf9*5E1v40Mcy^J_m8IT&VDWmwfv_sXL0< z=CJeBLN?gTgZWsHG5!4gvmgT;z^!@y?xpvNP*Jk`PS;J3kaqU<$(=m~cH_En0fSGy{pt!_!mfn!b|Ke2YetlAT_9;NGRJ?QPW0oY;dA zoey`+g${YRxIU{e28Qwr4|<2Cn~SXN$FH@@uwB%gr_2kqm{c1Fj^k5|AUbRkZ^ z=`-l%rqXBNJtKXNJf?1<5{+7+LQii+2`R6bxG4HcxM81Qeu2M<^|IB@gx^J!M}1=1X z+Y6q+h*p$<_OXDwX@yE~bG`BGi5u+XK;|_(G><|y{*1CFXUD(gz@tTrE$aUor~iiN zf##Ce;_k6y6%Ifjnx^_LbYH65>f%9B+6I&aSwJr$A=^H?oEvXv$biD zrT5z#bBIq&d|P%5<(2o!@C8WSEx_fWu7_rw(T_d_qdAq87r{Z56csVSb_H$Q2vJ0e zq5{Zf5F9psxRh8!t<=%g_09Mcc&TT=5fF+_o_8=0% z-+RQT2a{!ise}p^+#~uspfx_IVArSkZfIz0Q$c{XCkjkHc6fR z+#@F7b(9dMckkXgc2C!OQ9 zKY#vgBhA91K!R#82EQ*z%*Sk^VtEHC|8m?8mfQSJ$pui#%bFhP-bQ7<)cB0 zRYc_GG5)!8^7a0^rCz^p#Owi%q8XCp@P;!X~uRMUXtkMD#Cfr;FiZg8vo1I3dy6pO%-I%^Wc<&(G#JfIGWSpq)h zo2fE{>3GN>4GkVYF`HXj7&*P`!4ojxTiED$ESsv0cA|kA7N*r@b zOHoZt@_^J8w;sGR%vt^O$L9uBm*lgD4ZBG%UM#2HSuD0N-`3}*fG}QtXY#$sZ767B zMsoh%LZDE2>^axQ3>+ZJff*JWKLo4G0{A{}K}p(4`_BQPxUzw)H2TR`;77Gv!3T5T zSzvcP)+QuHod;D@(_Xv?2W|{9_os;!q6-(Uybb_TlLD!{)8dr$)ahWQ*O+_g?) zP+%gT*K|W5%Ky{R)9+SqL%$=A;VKHlA7bI5_xS~AA1yL0($4b&807J4^}wM6=4nCc zy~;|_qC+bFTTQ6@LS|(^D#@M$w!uXFcNTTmeD?p0Q>nx;v_d6ZxscD}42T;E0iYkf z4V8tKnBcC4Kt1+@2M>VKcQn|<{zr~=)ptYvz#<|-4s2o7aGlD?=HHbPl$=Nww8ljD za!OZA3Gxu>D}6`D%Z5PxVPN5A0BuL3jd=)Iq7(oPA%hI{dfhe=aQ~qY&saD)@#5p- zp*WC(-3*7y(uME?%TOpjIZDaztWPt#*KOiL1VBqaNFt=$zxz}mn)TOs+M>;s$hbHa zfPi)N^<_}dLq1Mk-3Hj(4!>`Qm<_!VcRxQrCU$llK!VLsX$S=!AKc{=fw&H-=qg(1 z1)l{0e8+&o1cjO?B#hZSgI&U4WMo89H0(-5dV0E=EEQ}tddjt6i9ex0J2>9%E4#ma z8->Q|*<@NNVy$RL!gW*-X#$cxTFPhHYUx`xS~Ox}Vv;a2#zBupZrFcvaAN-c!>Jy%-=JVg-bB_$P=x!T`pBAvg}B|KSB-$Q#e z%*+@F$;FGI{N^7sj7lu#Ez$r!1Ob^7u+_;4wLCjo-NHS{YALNEr>Cyw9baPs<zYs_mSvHWXdwU*;|_xZ-_Hnf!ViR@K03_KV06QYGb2}fX8 zU)4mSBD)?&s3n}w?4IgA?%NXt*d?5`S{6?spAcRCsl4eXZAuI5RUTdPS^O=>)X}_d zO59iYU{WlsU3+mhmRt#}hShGw@QbXwGu8>e6t~F)$7s-2sj{O5_grSeFNJLmlC_a) zM>9daC_I!reN)$V5MV*HAA z^Ri1pC@)^j024}<_ATx`MA1{j-(?=OY65Wi7>JdR_x?NqI>PYoJ0-g%zYI3X*~vfE zTGiT^OOWc#ilv z8?F7G{hn*-)p9LphpVZ5hEC!@t(Sx6YXC1lfWZU;Kp2B9OGQRT>h=VG^H}A$l$n8- zQS*3De)%I7e-?YMhT|-<`XiqhZhnuOKkPm-xriOo*5BXeK$@nfEp0zGHa1FVzl16W zn!`~g+gn@Q9P%vX#^>)@et9%W<pONt=Do@qX4YjX@c8t`r;nq8i+_1YTqvhm4y;o7zMOotd{b&XG-%_z`j~>w3%dtY zrQqevpc80<;_{0VkdLF#Y(z85ejr4gw6ruDIVHB>dCB8V9kpS_`V#JL8k|?rt~?=! zhldiur*#KX5cyOL`PsPtM+*R5UEs)^fg%PpHaiz5rwrMigpBMF9308RhYx|b`Ecni z2aCq-MfuLhmrZDW%1ko_8}_%pC7xD;kvy>~( z`bIru&3?{J5YHa}%`)jhRlt}XBltl(^B*!njSxHYcd4IB%lYI`CwqE%M2ksACn3o? zsZUG@oGhWE-`>IkgTsY&9(9kcR&cV8b6f^jTOUgioDZ?%3&nd-@SIM2CXG)Ty#QN+ zcg8Qzgu>9K@entffBwg2Am>NI&a*9eCgOpgEM4vTpX=%bU*6@l;AVB%EG5p^sb*vR z_x(TR``NC|FJY-}q3*28^7v(!-$iyFFQKsP06G>6+puHhp!$xbq) zzk>d=em0w=xL1-BHg*yeVEm1|Jf&P zy>@42lk-VW=8zx4XdEHCB&AcXr#P%EWAHOk-j(b>vBzt5?qNb<7}Flcud!&pe_tH> z5lbfhmHJ(ld0mh2@JQyBwtshe4L>6@c1P)jDjnXlPsLj0W7wsD(;0&fm;tlQ%+5Z7 zYAax8K&5WzO=P9&gNvy<)k-hq3tcO^91@{C45fD0D9 z-A#?wk(7~m>IsPvElrE=Ez21JW3HG7BbLD^j8_8mgCKy5p%+83w?6H@&=J`&yP@mB zc8yp7gSKz0#_Vfcj|Ju(;Ct__jy|k#=)*)}8LmE&J3YXHEIx9S24Mo&eWzoR_&OnXo<_4*+?p&}ZmM;_*D#utvEx%YaQBxW%0PtDBpfP_$44>)ixPw_y<$ zZ^2S1D8VpNzpSLKS%=*%+Q)%jqB#^l8FD-577zp|kBTYSH+R7+LRpDYSr132?tG1nEweC1 z(m^OK$NlP#+vH7>UJEnOr}{GP-WUVBs=GtqlEa&YH1n&p)Z_AZa&P$s7 zjEXrPb;Vo+?E;jBwm@Vq5QRnZ(!~X`sVtxT=#|HLR;6%QJ|+vsIL|&GgW?HfG#Gipcw9(EO99aS_*XV_wl(``z%j@^)zEb}^Ca+9&LF58}D;$kMTVpJBI3x$Q~O1Vkz; zKR*#robAA?l-=&3L7HH5bt`UbiKwV3Rcl($JW2+nb&1IY-;+-MF{hX&(R|k|cL>nT zVQ=5EqS6T{$1{P=L_z?yWkFy4->OKxq!L)m%H5^}J2ZLUV;VyP6EqnpA&P;4L9-2L z)IKK<;MTK#v$PibXH_KbnDILEb9eaUC=D>YJv^F%Pq!H%?Y@$}$ifm|X>G+&S=6r@ zshKMiXr>m#%Z-5@0#v~qH1mWYqbiyD@v=$krWaB#kE?Il>%I)Zm;rXX?bCWgbb(O` z06)w-cka+^115IP0nawXCtLPk{_0q#uRN^yts;c6y0}B9gOHaW<7reB1_Dr9IWH*Y3afFH8i3W49W= z?TK#%j@v_zNg96xw;kh`d6S)e=-sDSEt94}ylWgJh;c)JBy=+4pkln$5O4_*e6Z&V zJ&LjJc+z;j*|NA_i`F&|V$T9uoJr*lu?xKYVXc@YbM) zTZ~O$xs{FsiY^~PfTW=Cuyc5ODK{Mz)<9#DG&d|XG}-k~iA|38`7=5&gB0FkOlm*^ ztSYgNTGPNiLbJiHd>3syf{KeBq&dWLa&mA%OrVE_$F`z6s$~Mm&bhGL(KAe8HH-F9 zn%u$>1WSbre1Uiil`KT*IA9H!p%*$GbX3$i?6o<2y{z#k9%DcV79J6*QURj1IYZ&G zcS9~{n&yOj!2LL)BO-azKVxP4QF+XNQQUdAXW50xv`Hqo?=n_agS`PXPAI6CQ1F=r zeMpd{aARU&Z36A|M42!59j({#?q!c#9Q{BV3cUUmw=`)@L*_n>sGx#1+j~#L`m17$*>C zHA7E!r-4&emH}9%S#79C)NEN;zTU1W8GM`~&&@@d#kn`r&C#UcRL8aNimuuNc#hdK zb8~ZO%5pZO30gnu9R3amnkwm~Z2JTJ`2vwrkv{j&rKSAMYf4+3e@vQ)e{7*CojsqJ zNh9R0J;XCJ_j;6Tan@plq75hr>+;>FwBkgdToQ*$J{*c;+xe~}w7~`>v{ShcNxY0; zKC7f&`Y7NRxAGcnL7*={4MK2wFh}h9169Y}`(wH~QTyF)#cAk01#mi{=y}P=&@l$? zkb?cjKu!10!UV$8o-}<5>HLwWzkhZ`ltlCh>3Z2ZiT&-Z3UR=8kNDzH0aeh^AMt?w zNncQ5iJ*NNkVm9=o1-)yw1>1lYO*9Sat#qPT<-kw+=a<8ZKNX>Y##a76$9i50WPAX zMBpAMn1H@{EzQeP?x}ZI27NnqA!2Alr(esmeYA7y+P^H3Hr5eyoh<|+a9r;aXnn3*Sfoiz>zvWDWIbssVM-w9^2P8< ze<={HifG;U!Xu5($0sXZk%feiL{?6;95@skh`1$OpTr3O9tngyGhd>}(Ojk){dwS< z1QCJ8i*};v#HoH%8RrsELiK}Ga<** zXD5uZ2QygEAO9CHFN7a4Z|i#4nGYVl|GdffioWZ`^&Je}6(CC5K77CswPV0VIulw2I$UK%GXSTyp7|4jzuPwkAWxUo)703Q z8YDZ>kPnlEgjvcB64G$GdF<`%yS4{u(ndStS`82bZVKSSs&b^Iq!a<=Fte~^9kH?B zuQOCORPL$>IgGq^2(DIY+aBxp`;9Lut@4CD91*-TA>q9|*!dJ9cTvLK)dzl5g7E=R zPoR-I=wx>U7I@9WLlEdUQW}~kz>NY*v<4a30ugZQ&-~93w)9~x#3%fU<`mnq6$(Ud z>gl016VL~AvH{efFKdVHFa`pFK@pI~znzhi6?*tfNpISa2#3#%G7$+656=SA2CiQj zIB-%>*Zod=&^uvGN}A+OO+BpM(Y5iVP0U0S^8lD>ROt)5jsjf}aG~7^@6}OK*n6~9 z02Oa-3mXekGIIna|FKwjPG;whPd`U-8ZLUbH}z6OEW z+0N4UOr6Vu0FMdG@JLN^w+OLX)bTsXeO+tIk_oI#KnAurTE?1of8i3aPPUL~j7zN^ zj&k-7646P7;GCz-0Ffm{c>qfS-s8A#8=q%(kx;9dXyT9{u+Xvx`*(mbWCB4kbQ(XS z@W9T&0o9HutE)fh9C?+V9tZKn223sww3D4LwUvaMFuc^l40-B8}L7s8(b{uLFp zY!c0Mbf_L29Oy4f0W}tbik+GsVkP{bqM*oxG!yXV9IihGXr^w@-4T_%{Rv! z(ow<>^n>DKf6*2=U~5zd>EYoK^7t_p8U|6pCG<5f^YIP&YX=0#-1YKmO$Vh!V(T@f z7aHH*&K&SclnVpwwuT^7maNef2k&?c^*z8z-704TG;DzhaF_*w=mVA3#W-CHCsY98 z&gSXSdJCv2R;~h%pq~BBPKYEvNeDn>6Mae?l;m@V38bV=@AhThSvvrdM?Py%5oHmlEv?mgM*FLY#m>C=2M!CU8zs!0dAfw&nV zxWR2RaZ(e0H7@$N=MW9{pSmQ_MynwzPpPffNFpN%6TbDu;m9YD@3|!jMAx2Aj_lA= z`*{990sq+{qY15A3$17S3;O?5602Apiuj!*zAlIVs$8Y}PZ-exY4jZC5{MR-^w=2;cCYy$n%{g_;v3>{y}*FGPI? zPa<`{lOXVqvMYVBa#z3jVaB02(|=iqd-%!dWeT^(IR3puwYKbWyH0}0)CAiY7zPccKkF7FIU%p+Jv}Uldm54D-}ekw;bkU1R^72Eeg&o{ca5^IaS9CTtFR z;exXk|D_m&558c(P*)8H0Q{dTz1s=aGtwP3dQy1(unl-XKJowDh92QhU>ZXytG&8bdQ@Y(eX?hRdJ5l9A1xO?c(gAJb$sR3`mJujlosrQ5XCIFv42kqS zI~=M|gJkEk$F*33xu8yUs`7>#f|<8q z>lnz?)Rfe3aBF4(0qwbn$Y1MC*}O;_+@YW#o7}Q_(0Z8J?U=`!ariS>Ah?9Ya2+^K zQU-?N3N0HO7F1N44y#d*O^EFNJYo(^P1=Kz?syV|;=xsEB!rBj&-GV0d=`bYKo6j* z!FspZxw-h16rR&}IXQ+lp-OiA%hxPF=`iBE!OpEP*?as>Hstm0NYdKJ(5JoPd@<hf5*}FMtsYynQ z)r-pn8H&xx)Bx{OWeSnIeJ^NJ*03bpr({65$O4^f&=FDv4;Px)uJuQDJj(k*kZ~$T zClFH@14i(5;6-7ymJxDa`I?fR%qo`SBH2dZu_?33&kf~r`|lt*VBx~X7k?OuvQ`DG zKfi?k*Eoe0v;8+1ul+YJn>YQJjq}-Uv0{|>DGJ3?FDKJWklGFEfja9Srk!JBGe3?&W{B`c%gVVUNM;(8KRM#}o43%W}0e8aS z2+Nv7Ods#meLDE{@@5BDokOX4NUliEjx!+@=aKpo%@0NG?p8gY^&eYOe(JrAAS^3K zG`EIDd>ViW63seYnX~?dGO*WuxF-p6DB`PcbHqZ?0Q==6}A& zJT$Rk-6uGzap}{SP;3SH+2QXJUfb25@7+;<@O1qf;uA2*!zFyun>*e1)NBdezLxM7_}yF1gca+FDS`InB}An{T@gg$Etl+#Nh+uUhJv`LN{mt$AJYt9Nsg zkT$f?lMY;Qn&7@H_Je=D_i6=u2+zLx=V5a3nCfvKF)V?r23T4yhWVj+3?GwCLc%@? zs5_gDw_jNIbus=-dg;Sm%lGfxmRB-Gf8><&u+tJ*w)VAONUzNM)A-!JQp?=Z_vwS; z)5P%I(I*=E$$MY9Hp@RTUpl;=^a45c3X zj!W4))}xpeXK0+xxp{SPZ&+5N!RUSSmzZ|ubMjHo--h7l&&91zkqk{#5%1;R`So*L zPVdWfv}E4MWL25m$55O_*TkA#QQ__A?sDzCk)=%UrsozhKGlbZ2N#Zmq$XMROpwd` z^_GVZ_`4Y=X;!9k8W_|oiHmZ4FD-n;Q>wr4mgZnKEa#!rc-^P9yo0k#Cn-y=6^DyH ztFkH9!-2Gjy1}F5WA=tY>>GPOt|s+5{Ia#!3R9m99vsguPM4l~LO3+G5WzZ9`ukSS z{OSntY~G)_Cvay=t~dLp>8I}ClF1Wcv6^Wje5rq<1xMR21hP6WyH+g7bL$D8=ba_i zpETnBn8|25`2PKsv$c9XA&gbBg}4nG3#oCA`}zbxMW|k%wBciLvfys-)wu z9{Z<+;`uL4axSdN#C0FDs2Nmsn|8Y3M11dl(X={F8 z8>-6tq+;F(nJLbzcbS~DNbtz@N5pA~T%0}$=Ce-6dBGnBiGn z#E{$e=?(a%zh^}2XD+b>8JSAsshk!cliy)>aME>Af~V)63RjD4q?CugQ|cL=mkU!i zoBVqDXsYX!h2m+dkXiGkp!@He7bHk7N#i5DoAUXm^(h(pdY$Q=7xC^l)g5V?yrz(! zbnDhtc=4_NI=y>-t@td~@?3i&ArAW#whcZWwJ;Y)2xYORJ9WJqLt5smGh%%i>ZP#? zQ2~sVeB?zDnc-h8Cg1}MF+|t3m|mAH+Vo1W?{f;dpsb9r#4S8md~uGvC<@W5_yx~tWiH+>L}=Er z98aAKo@2#J@+9ez>1+IOz*t%1JrmsHH0y0q?<2?E34LeoTz}?n&ta-pz1`Af$BxbR zF#ml^$l?BZ;j6Ip8_%|~w`VLh7PWUb_)RCbvn^~yj10aTZ`t4nj(pK5#QhN&<=h(a zcK)YYNYD!pu6o|hHkzBf1?AeNU+%EvgcNkh)M$d5F?%-{86tVN$qIb-FhmtBxjap@8MN;w zFAJx$ua57J1fN-Ie2QAMzD0JUjnA&}1zCmAD%NF7u{TJuNd+#DmdP^rg;5bLR3n)% zou;a-E$-`>wY9Tf9=e{orLRXA<`SDi-BtWGE8^k(XM<{zB(ExnGv4)XC6PYE&XLf0 zZxHk$4A0kJcFDYna;u{xf-}BtKfUXzp{f11WAUh@?AUAV-*?!^<%<6KN6HNZ5Mz{e zOSmw<>e=>(NrnYqPmB;Lq(w>PcQe;M?q@r@fgnc+|NvFBn-oWUH$84 zr-y4$U5S+~^@LL`vpbM#Be8f|@4ZpN->cD_5)_Paub-#L^Zp?#b!x-VPp3BSh+s)9 z8*@OFcXKdQ1i}!iTquznU}jhoJWjK4*6jBn}T>n`O6S$%vCn~X=Z z>tI@cMo@Q)PK)%lF1vk0)oxhHDNB9e2khy!T4Q`&r%&Dm@~o8RgB8{3>McX7&0Xg- zo+miT$nLK1-!g3&tF|?v`nHWBbNHqb@!+Z`)sy=!DYVB~hkZQLlK#73d*wiWnR zw{LR*>-}?my+mjd6w9E>9X>=GT>tvuUMMyODwTrr_nYQ%9SVx9#TyM%yO-A{AHL5c zk)yAROeZolzC5oNz{z<3{>MaJgMpLKFSkfCh>!X{q$O3A2jl){=;_BaE`Vwq0 zUv!ZRJ)N4w%zHu*Z-%Wo>6iD-mPf-tKg0KEENLPPF6we7W!9;i6!TfvJwQ>HS^s*N zgl9epC;w#Oua2FY6cn<1(-nV3-h9-H%s2X`N%zY0z0FBk#32xCyD~CnjjvCq)izHK zh|>kW{M;Vl5!P3uqf6as^Iz{hF+Sh3;hR_EV8vMLD_6`)Md10XIYvF^E7LV0+D!ja z%k;Wik@+uq+#7?Wxi9X%cMir&dE);d`O~c14T8_gawkwoqLakOFz@6zUR@+{0!pbC zkYJU%PslCeYUIBQT5On}zS1sHTV*ub%Z7 zH{{hAYXJEw&|N?*h?)^D+^)dSfC*4&d;noZgcedwC!u$M9HPGB{JeJ`RIBhO#tE2| z{0-wF(5)Ya=_}N@@eqA*YyJ73OMfM+E_wO#rOAl<9kCPyQ~uU1I#gJN3e$k{L=_qY z1O$S19VBRfPf1DN+BN#`@A&`G0!U%d30fhbK1Nj|Apao;aYj3|%qs5?0<*@FKz;<2 zlBSR^$-*FvvZ6#Y+UW&J_3g4Z3V;6m0TK%b4=<&6j0gLFEeY<{#`DM&H>DmbanSx4 zj_rWV7k*q}b_=e`LW?%dQBe?DT%xjMv=D`t3E|~(M^OBSDP}eS>yYj!33Hw3kSmNM zYmrlv##7irPh2b$D1SIF^iyc_9Imv0Ua0`a4WGh{7pxr9!omU-|3?tfqdLa29#DdT zEdD$K+Kv##O!y-|hR6IyHjoAcwkTZ!3gCfqdkwVHL9?P~8G8zCKR97LOH0d|+S*L* zbXj<$K>rgrpu=FUBoyXv;4}u_8}Jm~swG1Nfoz4g%yPgq=*!UggNgbFBNtO5BUMqk zSh+K};lbXxJ4*S%$l66z*sH9Ji)w73fI|?VQiY%O(b`H=xCAgU1pG!S~ng4x6Ea@6hAR9=Hvv450S$%FD}9 z)frHe(S5XdA<#yrhhaf<{;}-{W$RFd9q0fd({M~%C@CSxLns6J{s~>t@t5DnFiwNg zWwxf!GKJ}F0d$Ohw({vyLW~gfri<)>R8>Q@Wgh!$=AN&CN&>c(6CH5lc>&U+^V~`5 zsj|Smeg>wgqqCC~g#R$CjSW(-Oi;@;1E(Z9Y_JyyB|imdf!+!Q104;Mu|T2vSDzyt zNVLUy7FqZjdhw~LsR#n~yNQX(WiBoSXz!q%_P6VOU0ux}tBnPf<0Vuw1~E}sM8tDt zm=DCQ6mXpuV1GrYL|~2*#^E=8z#?=^O`pKPD;9z_D<&EOX<@c{@cnF3prxxTFF0oe z4E0BTPe@2WwRLdpTkeFw?x331AsDE<1oLPFAZ-C|u?cz*=u;4YhKOm{hZIJFje+_H zoadDwbQ&CiL^|T94c&H83nNtJ`RvV`3y6&(6$8T^k4Y?C!VqZ7!E8Cg1S9@1jX{n} zM3)zTnT+(+YhXf{Sp3Og7^4YVg#&Qnn$VdJ3JQXzpo)!6ma@GTW0E=!LM<001BUdz zLJJ)yvVi9QiYwk1;N?2ju2;@#S(vaa{5X2hP$U^V< zx&qTdFc%d7?E*N3b{=S>9neOB>HxSrJBq=%gR9Fx#-QV+k8sX!G@hLhTU%Qjm)YPz z4?K4lgjl zg)hP{&}lH)zl*F8X0qTGsKxK38jizppo>`?%qNG|^jW&<36nj#raG4#gJ@fG_IX18NhmV;)F=k}n$a?UR!6DwhZd-PSM=cd+<2&ih|y z8Ibwx%EZJin3+Bg(;^!?JB1s`kSZ5D=jZ2{FJJD+ac@Ytj%i$EdIcuzSo!$0Z2jSs zKx@p#*8=TGKGVu3@Q>YUUG5}!7KG{dt5>hSLPvm1E}5-0ut%StzH#FQav}OSsd%OW)X^GRiT~OL^zLQ$>MPw}u%0479|B zK!^^r9@wyP$#TK65RA+GHf`$Hx%l{6p&5ogEC^z~zD*^%rwgpn*roq<8c3V1PUr|P zblUdTCP8Zo03<9uoe@S%OfOlZt1`jH^TM+tfYB)EO@%>2vdud;f#_n;2EmH`+iX;P-76n<9Yxh-pH+R8d>s&M`_#{I77AmAe=#od6$$i zS6l>T+hL+8^2rnD%{pjvJBre*^x-~!$c&obtynh9jNDvKm@UW{axT)SP42nvSY--w z|Jv(k=;#1sbTKz{q)OM)GS0~2es+&eu0D6BKKJ<91(*>dm5gV@WJ2JYF#XR83KIak zXn*?JHFQuAK>)8PL8}~=KQlic1``=SWTv71Lrg2l;lpY%c9>QW1t7_Vfg=qL4l=>4 z0|4paC}sl#gJ)kSw-QwT!EoW|RvS|D&;ns9URX@V3M{A#m(OD8?1_oi!$8jXEYj{it?puQf z=1~DFtTTb(CkCoeg{~(Cf(lc03Wg+tjwR3*E}GOmd|;VM-&kc3F)^_$s6GA(GDNInORD(u! z<%cpfX+>!+fPe|rkk-}7K%;6F>=mA6rJ@J;F&QZCI%;Lq_~nZd zA&uxcK-72$7&_EObFUoI6mH+nF(0Rv@L+_3DU&Z4LhrN&W(CJr9YkrY1*u&bl-SI}=7;>8 z&GWSmutI18$whKv-OyPTYf-0p6|B9;j`J`R9l0SW1}LmwUD z=F1I_auC(zARlzD()wgtqztp#DSh8pc7eHiJm3h?qRFMCJ{)9>pL$LK?Iw#j#F}3r zjdu}w#g|-hNMb5|-3EgROVDAPd4it4`}=35Uw1>bF=77V0+`923&9ACGiD4pmV&S| zqTwFEB^^VeV8D@AeO{SuY3EM&Pg<;ATX(@DI@`(3Te z#f4rLx=Koaln&UQffZ+FWnqHqs?w<>FE3=wwc3Ril>PAhrT!&vu|HC8id%mr@N~}K zMW0N7kkHabgFXV4?)y&q7!{nryd%s({=0*MB6sQINmV(PtXL&Kq`RY|2?hpYp^0kZ za0;zlD$u?`g;eT73#bk7++)LnR*01tqS}}P&Q-uFmQPlJO;E?Qtpt-Ru}LbJ*Zrts zB-K@6kwM@YXw;~byW9@{eL;#q9uYqNXH;y9ielFO{NDt>$5)hYq)L!NHiJqMqd(xP z<^NIJm4{=w_S?shlzB)cLqwAZJIW9;CbS#LJgZ)_(5o_3$}1sMK2b?q*~yd$2}vkJ zR8o|AiVUd`;jFv8_xbj9&iT%Du5+F9&u(Ad>6z~P{;l;}Yuyk;;I`Xs5++iLPNLRD z{oOim!XHy($l%msJm*lFzGa%la*>JMRB7WP9o-&U=t8YRtEi?mJ9ifQeD{o3SKhR#1G9f6)DN79PWU?5!~q^a$HKy5 zrdUDJ=kVe}&Rl(i?%(}VFEA-fjE!H6HotjOg(;L_6??&tN)`PQkvl z0s#~x5nx~sgK;oC|0NRMF8yy;?CrV5DXk@Y=;c~S))z{k_E_afV zw&3RfGCh4Fd{ z;cehmr0*4{ciHmlzBD~x6M6g1rThZ6v2nxpOVOM?Ygfi_bbWrvQpw{PDL!4mcJwZt)s*kig7Fr$W2iX-AF zbHvkAID-!H`Kg77)>u`fF6pz^iv7xOwNClT181wvwaD}M-3K!`DK_b)RqP7Dji<; zWW5;})xBW{<46NmX~_*a+3vz5PgFBAar<<_X-P{9uA>0T{8UEQPw z#xI||*ko-;r`%i%<59glwv%Va7cg=|43AAE+>%+u69TqQ2R^EZ2@tx(bxuC0^1aGl%Xkt(&57i89|`nO~TC9L4F(+#fo zidXGq^1{4HOr3o%u17~}OtV9RGSFVCS@L7th*MN`pKpM+cgAW-?-KQWj%J!R8}%|B zn(D=e2anbtD!(TFVr8L0@@W~#n9%FYY$1T!okSgn+73Bokkae!6D} zkEgj|W@pd4FaDFVs{WNHFYPjDkYMhB(ikkBX9qFVSX+Pj`sL-zJs4ImN8AMk1+{KA z+JOc^QLl#>BzRVKTUxf#=7EB{Z-j}J1FHflhoWNU6xaAsWbl+!Vmu2&43`bw)rmU0RlwUJb zwA?B^eEZ!Ym5iBQ`>QN65;V=-CpG*hO^Pnv4o=-kr(Pf0ME{giMc9Y3nxO~3_v6vK ze_XT-EMM|(zp>&%zEQ@K0Hr+oChzhhD=YP_is^hzOz!Q?uMTZ0x08RXE^^XUi7))A zv;6jRg! z4Ws-QUSVOHfMoDSEVAn_J)ef=?}2M;a|HR?OT5cN>aR`Ig%qo)zWsU8)INzc%~Un4 zt*881%2|L>-29}5AquS$pBr0v`}GBo3>W~8;7XNmUMPkAe!(Lf&p;ACCGky4mu;nP z@0t9$FL5^ll)bc_T5n#`_3ak)SuS)wA}8Hb-{-f}=@K;Y(>IGAx8HuXe`boleyldT zHEADTxRg2-wfq;h`RV3TFUa(%5aZQ4`}Z#)HUpI3t$C3!LZC1j@Xc@T8t_$RU1&R- z^#wh!~uwup8hc{1W zw)8(!Th;kd;)mwtI)Y4-3Acy za9h8~bf+IXCMhRZ@4{TwyGRtiQg;nc`0in7l|wWWpSXDNtI^c0A6{){l@VXgu)8$O zaLRtpf&Z`eqr#KyzEy0aJ8ZAv(^!7xiS3*1Z5a%!nyrC47v`(ASZCI`x1656SlyrV zG^2WhkWtt_hA?+}fDorJyX&CC0xHVi`o%^F8j<>A6FlojB(Ly|kywEB>(?inrYm@N z>4J(x?&Cr#+wL(l5y66oGeBgBpIS*t$=J8NQEl_nk124tk8DP>8@1XNg>EnAh_9tT z7+52c zgLW{UDYqslxe7t+|3Sm^p6J>h#Wt67+vR`dQ4tgONgq&Tv~WesO1ZIm!Sq!yl=+Zk zaZsc|gAPd;G^?a>1)D`VoMrEBl)oMQypbJnsrgCwgECjD=J|8ConPSZCMcE&$AL?T z_m4+oN^4&L0Wh)B8z`hSWGN3#48P4`S*3qIdrtjO!^$w(Ty4DEQy;oOE6*bG`STrA z-O+K(OeCo!qckfk%i#7-GutdI5e!D=^=xVj>H{Yi7Y7vce{|(|5SZmKI}5=vxM%4* z>IB6-<5%XOV+8_(DgruVF&?Pm*@;$gYxn1Zz)F$`ptN!Fl~P7isv*Z7iQ#ska#kMp z^YcUeMBbt^qFe_7(D(|)#7Y-LQ8u^s{BGgS!`>0-LC6=8lSc0d4FA7H!v8&t*5AYA zg=8lDdI(I_pwo9-S-p2@M>q&KXgrcl>!i3G`5{RM*5Sxvw!|(9LD&J1#XoS{(LzQf z%xTi~eDfy1r8Fmp??Kx!88kKcL_|LI(8x(u#28aZ!(Mld-n9tC9g%l~NF`adAUG|C zHF39yX$<#tC6Gg0x->2{zX3nf7zzQ>u7UPD&qOYM6}-rJfSF|oG9RR?>n?2&!>aeU z$r5?HjUHK!fNh(EGc;Flp7n24CWoB6-GQ~i{BML!qcr}^XT8~wwd}7+Nj1^`>dxj& z0d;h}{z6LkK`Y|;ll1tR8eY8;0HNAI%X_1o`;IfiGy+*J{>MdA_bdX+Ok7BQCOxJ? zDTD$PBPAC?m;M~rTwGj0ITU65H~69M-GeFV&3y29kKx5jmmZ@Rgb*>_2@U)lL&QXp zNnTc#3!-ankO4Frjesol5r!%*+_|&9y0?^zPEuSDEEw{wA2TS)LtdupyB2o9kx%14 z4_V(O=fH-P2(SHYr`L zsAq>4*Q6@|f$kehyz4Hpeq*h$Xyozfv+Q>f9bNrsOi*4$4`2hhm?khsCF{3`qKZz* zF1Fa^j4?P8#&kiX-YMU%zE9_nd(rpae3e9=$4-5rzPOPG?I@`*nasPc3gbtmIYMZG z0_S9glFS5gJy$s5rGLkel9EL#oFP44f1*GFz1g&U3PP)?(6mgk5$%0-M&+QtV!9Qm z*eC0H6wikjPAhJ7bkHsk1N9UWCyzfT)C!##A8v{d_}t4AV*qz^y&64yFrwk@RJ zT%AB)!@HN+xp6l)2l=86leG4u%?|*4VSAqmy2EpLsf`PYw2d+MzbwJeZ}AU zal{UDc}pJneyNP=pO?(O*X|`G530JVd~ccDr^(LI9aS%qsv7icRSHwyNb9nd%?q!J zwGLGEChsDLRkSv+`>FDic4xdVtmJb_-1;Jwq`7UFyVN;D?sFVZ1mx0C!Ze^)>`U>3cLHG{=?}xpU8I)>dOpQ_S`Xc;lPF5W!WB@ELRtC z>ByrUIa8iKj+yU0PhPzwZ@J;EWdK_Ur{V4o@>6d--baIpYgjP+{qy+`fjU{<3%L(= zNb&mUT^CKO)YOzSuzs?@)iV3~QkUrP%B_J}CD|$}`t|`f@u|Jor67as5uoG}JT6M( z)F*G_&#n5}S^8|h;AJyM+;Vrhs=O1eyQgM+wMPnq@Kd4Wr=~kEeh6$(bB#n&QzDj$H9i_%%@ETLKdy_*TlNA#tVvVgEL*r z`rDh5E!tTVjL&L*5MoF?n7#4jyt>u*QCoVp@cit~=9FI}Z!DTWV0P|@0h@mTi?ii* zTZ0psS3kEuzm(P4{EWq@bXsiTufi5z(-oGfRg@YU%*+*7y?8TvCubtKd5&A7m7b&X z$-qY};i*ICx5>@be-Thf{(o^e>-gwB>$UT#y-OwdtP?r4%shF@9LnFV=BVAA%~i-b zW#3k9$}@&)T8*c$Y>ur|k5&p|v^YhJ8_ZJr}zvi=$^`WhAP?7IW( zC(oB2&}{9$B4Gr}P3Cj+hA%#5+$gk7$r0tLj4JQwpO*W_N!!8Jd7kq%N1b<^8$7E} zbF{PUg#+7)D{|&u{ax;37g!AXqxtuoejTo<`8tZE5`lLlDU1pVd{3X6U1yr<3=>a* z35GC=*ezfTT#3r>FB1g>Mm83)NeAY(pfpHCEM^lYl zlB*)FpOe#xl@g#h7B9xkC=c@n(6i(p0cL3#8SZ)W^K+A$>R%gl=*Zpq3~we0cqMW5 z&ueO~T7CaE)1Ri1Vzg3{+B>)R++40^*o}{U`hwxA{I)!LVeG;2)`~3q{WDK&n~1Ab z3PAwJvQ3A0FK@KvrsNarP$*X+V)k@xZLQEO$<=6R2tml%fmRYSj{av)e}C(@E0S3f z^scLcQ(e1;IxjPlo)~d&@_rpHb?aBJgD1>htt{eP(I%!S5)|O{&^@}D+R-EYSM?7$ z!QZO?yy+y!r$9X==3_a8#Nzk5W(DC$dEsEoAg!!sND)`6w8rLaZJbQJo-|ja`*lT> z;RQia^|sF%@3<-_w!X1xyR2JT=E0C+?ttdPUNHfspnFRbO$k+@Mc_^o6Mz|48m;BT zRb)VcySc4*oKBzO+Gm0}c4AL&-n{bA&=BIHT7sX*X<^AS$5Q5|&sFF0%i!Q(1GtB3 zrzp$OXLj-{(KyCN5MHe}Xa=1nej+25Re%07xOS%5At$+{L=1^eZRf494hBi6uuo=md$p@_|M8Eh!Q0Xi z)$yYVQZ7u#f7aaG78!3^T$B@UG|^%%UVJ@x1X2LEX_hjizc#uAV?mJo{Xzx%|EPa~ zj2>iwU>(gtpf^d7C$U1r;*RXy7*CS)Q{5?u6o+fFMU07t2cEW5Gc%4LMGq9y6{rq~ zxO8AEWul0m6+2VF)OLWX6WEO2S)^Ws5ZN~Yr%U=m&<~w?6qdewtstpiJ31v2YTNAp zd5$<$NAG!m&RFw5+J~Q3g}eT2dQ=Dft1d?wx5t01xHc3?#mC3RoknEC4S-n?>gC`< zy?d(p8l4UIpiPM)3?ALpMS{;rqg^bZhmU065%K^@e2{r=EZPu%P%%LsxvIMQ`sLUh z0_6~i=&@{vi6r;F{Af7v+7cc;t z(~Fl}j@GLTt{>TO;m2g@{8KnbN&Gs=j`KY%=ZS_jiJHQAjRJ{G0$qvu*e3AX{>Ie8 zq_bz)Dk>`MaH%rpf`HEsKtO}8L=H0Ku{p$YG_?=WXg)rvBat;_zIJ~b`^n!&R)gN7 z_az(zC%LXb_2U+#zCm3_lydI_`md(c#DoGKlD!=0!+Fg=4=77E-T;Lwa3H}o?wQkDq>cJ<+2CR#yhMHS{ShLz zpfKQdGEBS4_9RGjTzk*zK{;O;_u#*%Xye2l+3-*Z>fyBt_a%ZGr10MR{^MocOi$de zM1uwRE)MDn6(41qWg}a(!LXn^N)7!%qR(KVNccVJVgmi}S|!}yXKI>q>sDpo!)>X1 zFp?iW$b9Atvb(9oZKgs#|LdXyJsl-!PCl3X>d7Owxlus~DG4ef()Fe1%^A8Up zh5F8&JFNSqL_y!HLmT!PyWEgnV(4fFe#Ukb2%t0YJsj)ved?)WBAqit^x{8Tx9+&K z78ydI7no&^1PVngAO{2e(r2WIkX&G2!&uT}N3=Gtv$OLX12iPWf~M?&2e#eV#gJ=E zm0q*v!GU`Ngu9V_@Ln=a^5Y>lw@@pObb@vM{-^17-w$rQ} zw}oW)j*)_HZj$fCqdxZ2r;7>pu0`GsGJ-*7l0Y7$dPQjjPIn+Y@Gq!{@GP!@sMV8s;IdlO>qYG6&(it(V19c<<+t8OGYl zFWZ3iA6~0$uN$PiO`~tYK%kMGg9on$OR6j_uZ4UM zc*R`w2z1>VJs3lO_NHyntoUCwrF|^n@D4Lx{RG^pUc9z$&pxaRQih5NpYBBCPZ-D} zT7G^?*r;Q`C!CRV?kKZ_q+!vC=z|n-y#fJZqa+9SFW3hPtOesGgAv-zzLL*ykC#{W zH!Sxnni=?pqegX*+m5@GQww zBn$zj4&*UY6nVVK{0?0x3Csq;Sb%n%*I384_x`Q!r`iFEjL3>;RzUwON#Y%$$D_({ zfFv|k`}Qk>g5e0B0>geDiXI6-V6<2cJoJ~v7z72nm?Bdv2s#ovK_WSNS|ucDOQ0-h zT!L_vyPZ7LYnl=&2)E3o`_h|GT`r)=;94EG2&ezB&i}mrDkMYnC8dNjG6jBq3a?;in{rUYSWadQ=7fYZ5@t|d7j~!5ZyUfkQ$tDmzJss%&F0mHl+nrAM7mt_Fwi05GED;0ma@P>P4U6Rs xJm)D!t8gAmPXUiY8Xscm=a-vEbWNcsQ( literal 0 HcmV?d00001 diff --git a/docs/_static/early-recomputation-05.png b/docs/_static/early-recomputation-05.png new file mode 100644 index 0000000000000000000000000000000000000000..17d822289cb8353ccb304699db4d19309f8392ef GIT binary patch literal 25410 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 CH0?$J literal 0 HcmV?d00001 diff --git a/docs/_static/early-recomputation.png b/docs/_static/early-recomputation.png new file mode 100644 index 0000000000000000000000000000000000000000..6e69b2842fd1aacaa20fccefb69a95cec99e4c61 GIT binary patch literal 25889 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 NRFyOi=PH=`{udhvI%5C; literal 0 HcmV?d00001 diff --git a/docs/case-study-landing-page-caching.rst b/docs/case-study-landing-page-caching.rst new file mode 100644 index 0000000..eb5dabe --- /dev/null +++ b/docs/case-study-landing-page-caching.rst @@ -0,0 +1,17 @@ +Case Study: Landing Page Caching +================================ + +:doc:`DiskCache ` version 4 added recipes for cache stampede mitigation. +Let's look at how that applies to landing page caching. + +.. image:: _static/no-caching.png + +.. image:: _static/traditional-caching.png + +.. image:: _static/synchronized-locking.png + +.. image:: _static/early-recomputation.png + +.. image:: _static/early-recomputation-05.png + +.. image:: _static/early-recomputation-03.png diff --git a/docs/conf.py b/docs/conf.py index 5f04890..683dd3e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ # General information about the project. project = u'DiskCache' -copyright = u'2016, Grant Jenks' +copyright = u'2019, Grant Jenks' author = u'Grant Jenks' # The version info for the project you're documenting, acts as replacement for diff --git a/docs/index.rst b/docs/index.rst index 9fe19ac..cea1122 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ cache-benchmarks djangocache-benchmarks case-study-web-crawler + case-study-landing-page-caching sf-python-2017-meetup-talk api development diff --git a/tests/test_early_recompute.py b/tests/test_early_recompute.py index 36f31d5..bbebc1a 100644 --- a/tests/test_early_recompute.py +++ b/tests/test_early_recompute.py @@ -1,17 +1,9 @@ """Early Recomputation Measurements -TODO - -* Publish graphs: - 1. Cache stampede (single memo decorator). - 2. Double-checked locking (memo, barrier, memo). - 3. Early recomputation (memo with early recomputation). - 4. Advanced usage: adjust "Beta" parameter. - """ import diskcache as dc -import functools +import functools as ft import multiprocessing.pool import shutil import threading @@ -25,7 +17,7 @@ def make_timer(times): """ lock = threading.Lock() def timer(func): - @functools.wraps(func) + @ft.wraps(func) def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) @@ -36,7 +28,7 @@ def wrapper(*args, **kwargs): return timer -def make_worker(times, delay=1): +def make_worker(times, delay=0.2): """Make a worker which accumulates (start, end) in `times` and sleeps for `delay` seconds. @@ -47,7 +39,7 @@ def worker(): return worker -def make_repeater(func, total=60, delay=0.01): +def make_repeater(func, total=10, delay=0.01): """Make a repeater which calls `func` and sleeps for `delay` seconds repeatedly until `total` seconds have elapsed. @@ -67,12 +59,13 @@ def frange(start, stop, step=1e-3): start += step -def plot(cache_times, worker_times): +def plot(option, filename, cache_times, worker_times): "Plot concurrent workers and latency." - # TODO: Update x-axis to normalize to 0 import matplotlib.pyplot as plt fig, (workers, latency) = plt.subplots(2, sharex=True) + fig.suptitle(option) + changes = [(start, 1) for start, _ in worker_times] changes.extend((stop, -1) for _, stop in worker_times) changes.sort() @@ -99,64 +92,77 @@ def plot(cache_times, worker_times): x_counts = [x - min_x for x, y in counts] y_counts = [y for x, y in counts] - workers.set_title('Concurrent Workers') + workers.set_title('Concurrency') workers.set_ylabel('Workers') + workers.set_ylim(0, 11) workers.plot(x_counts, y_counts) latency.set_title('Latency') latency.set_ylabel('Seconds') + latency.set_ylim(0, 0.5) latency.set_xlabel('Time') x_latency = [start - min_x for start, _ in cache_times] y_latency = [stop - start for start, stop in cache_times] latency.scatter(x_latency, y_latency) - plt.show() + plt.savefig(filename) -if __name__ == '__main__': - import argparse - parser = argparse.ArgumentParser() - - shutil.rmtree('/tmp/cache', ignore_errors=True) +def main(): + shutil.rmtree('/tmp/cache') cache = dc.Cache('/tmp/cache') - count = 16 + count = 10 cache_times = [] timer = make_timer(cache_times) - decorators = [ - timer, - - # Option 0: No Caching - - # Option 1: Traditional Caching - # cache.memoize(expire=10), + options = { + ('No Caching', 'no-caching.png'): [ + timer, + ], + ('Traditional Caching', 'traditional-caching.png'): [ + timer, + cache.memoize(expire=1), + ], + ('Synchronized Locking', 'synchronized-locking.png'): [ + timer, + cache.memoize(expire=0), + dc.barrier(cache, dc.Lock), + cache.memoize(expire=1), + ], + ('Early Recomputation', 'early-recomputation.png'): [ + timer, + dc.memoize_stampede(cache, expire=1), + ], + ('Early Recomputation (beta=0.5)', 'early-recomputation-05.png'): [ + timer, + dc.memoize_stampede(cache, expire=1, beta=0.5), + ], + ('Early Recomputation (beta=0.3)', 'early-recomputation-03.png'): [ + timer, + dc.memoize_stampede(cache, expire=1, beta=0.3), + ], + } + + for (option, filename), decorators in options.items(): + print('Simulating:', option) + worker_times = [] + worker = make_worker(worker_times) + for decorator in reversed(decorators): + worker = decorator(worker) + + worker() + repeater = make_repeater(worker) + + with multiprocessing.pool.ThreadPool(count) as pool: + pool.map(repeater, [worker] * count) + + plot(option, filename, cache_times, worker_times) + + cache.clear() + cache_times.clear() - # Option 2: Synchronized Locking - # cache.memoize(expire=0), - # dc.barrier(cache, dc.Lock), - # cache.memoize(expire=10), - # Option 3: Early Recomputation - # cache.memoize(expire=10, early_recompute=True), - - # Option 4: Early Recomputation Tuning - # cache.memoize(expire=10, early_recompute=1.5), # =0.5), - - # Option 5: Background Early Recomputation - # cache.memoize(expire=10, early_recompute=True, background='threading'), - # TODO: background parameter? or early_recompute='background' - ] - - worker_times = [] - worker = make_worker(worker_times) - for decorator in reversed(decorators): - worker = decorator(worker) - - repeater = make_repeater(worker) - - with multiprocessing.pool.ThreadPool() as pool: - pool.map(repeater, [worker] * count) - - plot(cache_times, worker_times) +if __name__ == '__main__': + main() From 024c4d9573313869c5dd713f0914eeb71977b53a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 9 Nov 2018 14:58:38 -0800 Subject: [PATCH 119/329] Add Comparison with related projects --- README.rst | 215 +++++++++++++++++++++++++++++++++++++++- docs/_static/custom.css | 5 + tests/timings.py | 48 +++++++++ 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/timings.py diff --git a/README.rst b/README.rst index ae0b086..6aad03c 100644 --- a/README.rst +++ b/README.rst @@ -52,6 +52,7 @@ Does your company or website use `DiskCache`_? Send us a `message Features -------- +- TODO: update with Comparison below - Pure-Python - Fully Documented - Benchmark comparisons (alternatives, Django cache backends) @@ -88,12 +89,15 @@ function:: >>> help(Cache) >>> help(FanoutCache) >>> help(DjangoCache) + >>> from diskcache import Deque, Index + >>> help(Deque) + >>> help(Index) User Guide ---------- For those wanting more details, this part of the documentation describes -introduction, benchmarks, development, and API. +tutorial, benchmarks, API, and development. * `DiskCache Tutorial`_ * `DiskCache Cache Benchmarks`_ @@ -111,6 +115,215 @@ introduction, benchmarks, development, and API. .. _`DiskCache API Reference`: http://www.grantjenks.com/docs/diskcache/api.html .. _`DiskCache Development`: http://www.grantjenks.com/docs/diskcache/development.html +Comparisons +----------- + +Comparisons to popular projects related to `DiskCache`_. + +Key-Value Stores +................ + +`DiskCache`_ is mostly a simple key-value store. Feature comparisons with four +other projects are shown in the tables below. + +* `dbm`_ is part of Python's standard library and implements a generic + interface to variants of the DBM database — dbm.gnu or dbm.ndbm. If none of + these modules is installed, the slow-but-simple dbm.dumb is used. +* `shelve`_ is part of Python's standard library and implements a “shelf” as a + persistent, dictionary-like object. The difference with “dbm” databases is + that the values can be anything that the pickle module can handle. +* `sqlitedict`_ is a lightweight wrapper around Python's sqlite3 database with + a simple, Pythonic dict-like interface and support for multi-thread + access. Keys are arbitrary strings, values arbitrary pickle-able objects. +* `pickleDB`_ is a lightweight and simple key-value store. It is built upon + Python's simplejson module and was inspired by Redis. It is licensed with the + BSD three-caluse license. + +.. _`dbm`: https://docs.python.org/3/library/dbm.html +.. _`shelve`: https://docs.python.org/3/library/shelve.html +.. _`sqlitedict`: https://github.com/RaRe-Technologies/sqlitedict +.. _`pickleDB`: https://pythonhosted.org/pickleDB/ + +**Features** + +================ ================ ======= ======= ============ ============ +Feature diskcache dbm shelve sqlitedict pickleDB +================ ================ ======= ======= ============ ============ +Atomic? Always Maybe Maybe Maybe No +Persistent? Yes Yes Yes Yes Yes +Thread-safe? Yes No No Yes No +Process-safe? Yes No No Maybe No +Backend? SQLite DBM DBM SQLite File +Serialization? Customizable None Pickle Customizable JSON +Data Types? Mapping/Deque Mapping Mapping Mapping Mapping +Ordering? Insertion/Sorted None None None None +Eviction? None/LRS/LRU/LFU None None None None +Vacuum? Automatic Maybe Maybe Manual Automatic +Transactions? Yes No No Maybe No +Multiprocessing? Yes No No No No +Forkable? Yes No No No No +Metadata? Yes No No No No +================ ================ ======= ======= ============ ============ + +**Quality** + +================ ================ ======= ======= ============ ============ +Project diskcache dbm shelve sqlitedict pickleDB +================ ================ ======= ======= ============ ============ +Tests? Yes Yes Yes Yes Yes +Coverage? Yes Yes Yes Yes No +Stress? Yes No No No No +CI Tests? Travis/AppVeyor Yes Yes Travis No +Python? 2/3/PyPy All All 2/3 2/3 +License? Apache2 Python Python Apache2 3-Clause BSD +Docs? Extensive Summary Summary Readme Summary +Benchmarks? Yes No No No No +Sources? GitHub GitHub GitHub GitHub GitHub +Pure-Python? Yes Yes Yes Yes Yes +Server? No No No No No +Integrations? Django None None None None +================ ================ ======= ======= ============ ============ + +**Timings** + +These are very rough measurements. See `DiskCache Cache Benchmarks`_ for more +rigorous data. + +================ ================ ======= ======= ============ ============ +Project diskcache dbm shelve sqlitedict pickleDB +================ ================ ======= ======= ============ ============ +get 25 µs 36 µs 41 µs 513 µs 92 µs +set 198 µs 900 µs 928 µs 697 µs 1,020 µs +delete 248 µs 740 µs 702 µs 1,717 µs 1,020 µs +================ ================ ======= ======= ============ ============ + +Caching Libraries +................. + +* `joblib.Memory`_ provides caching functions and works by explicitly saving + the inputs and outputs to files. It is designed to work with non-hashable and + potentially large input and output data types such as numpy arrays. +* `klepto`_ extends Python’s `lru_cache` to utilize different keymaps and + alternate caching algorithms, such as `lfu_cache` and `mru_cache`. Klepto + uses a simple dictionary-sytle interface for all caches and archives. + +.. _`klepto`: https://pypi.org/project/klepto/ +.. _`joblib.Memory`: https://joblib.readthedocs.io/en/latest/memory.html + +Data Structures +............... + +* `dict`_ is a mapping object that maps hashable keys to arbitrary + values. Mappings are mutable objects. There is currently only one standard + Python mapping type, the dictionary. +* `pandas`_ is a Python package providing fast, flexible, and expressive data + structures designed to make working with “relational” or “labeled” data both + easy and intuitive. +* `Sorted Containers`_ is an Apache2 licensed sorted collections library, + written in pure-Python, and fast as C-extensions. Sorted Containers + implements sorted list, sorted dictionary, and sorted set data types. + +.. _`dict`: https://docs.python.org/3/library/stdtypes.html#typesmapping +.. _`pandas`: https://pandas.pydata.org/ +.. _`Sorted Containers`: http://www.grantjenks.com/docs/sortedcontainers/ + +Pure-Python Databases +..................... + +* `ZODB`_ supports an isomorphic interface for database operations which means + there's very little impact on your code to make objects persistent and + there's no database mapper that partially hides the datbase. +* `CodernityDB`_ is an open source, pure-Python, multi-platform, schema-less, + NoSQL database and includes an HTTP server version, and a Python client + library that aims to be 100% compatible with the embedded version. +* `TinyDB`_ is a tiny, document oriented database optimized for your + happiness. If you need a simple database with a clean API that just works + without lots of configuration, TinyDB might be the right choice for you. + +.. _`ZODB`: http://www.zodb.org/ +.. _`CodernityDB`: https://pypi.org/project/CodernityDB/ +.. _`TinyDB`: https://tinydb.readthedocs.io/ + +Object Relational Mappings (ORM) +................................ + +* `Django ORM`_ provides models that are the single, definitive source of + information about data and contains the essential fields and behaviors of the + stored data. Generally, each model maps to a single SQL database table. +* `SQLAlchemy`_ is the Python SQL toolkit and Object Relational Mapper that + gives application developers the full power and flexibility of SQL. It + provides a full suite of well known enterprise-level persistence patterns. +* `Peewee`_ is a simple and small ORM. It has few (but expressive) concepts, + making it easy to learn and intuitive to use. Peewee supports Sqlite, MySQL, + and PostgreSQL with tons of extensions. +* `SQLObject`_ is a popular Object Relational Manager for providing an object + interface to your database, with tables as classes, rows as instances, and + columns as attributes. +* `Pony ORM`_ is a Python ORM with beautiful query syntax. Use Python syntax + for interacting with the database. Pony translates such queries into SQL and + executes them in the database in the most efficient way. + +.. _`Django ORM`: https://docs.djangoproject.com/en/dev/topics/db/ +.. _`SQLAlchemy`: https://www.sqlalchemy.org/ +.. _`Peewee`: http://docs.peewee-orm.com/ +.. _`dataset`: https://dataset.readthedocs.io/ +.. _`SQLObject`: http://sqlobject.org/ +.. _`Pony ORM`: https://ponyorm.com/ + +SQL Databases +............. + +* `SQLite`_ is part of Python's standard library and provides a lightweight + disk-based database that doesn’t require a separate server process and allows + accessing the database using a nonstandard variant of the SQL query language. +* `MySQL`_ is one of the world’s most popular open source databases and has + become a leading database choice for web-based applications. MySQL includes a + standardized database driver for Python platforms and development. +* `PostgreSQL`_ is a powerful, open source object-relational database system + with over 30 years of active development. Psycopg is the most popular + PostgreSQL adapter for the Python programming language. +* `Oracle DB`_ is a relational database management system (RDBMS) from the + Oracle Corporation. Originally developed in 1977, Oracle DB is one of the + most trusted and widely-used enterprise relational database engines. +* `Microsoft SQL Server`_ is a relational database management system developed + by Microsoft. As a database server, it stores and retrieves data as requested + by other software applications. + +.. _`SQLite`: https://docs.python.org/3/library/sqlite3.html +.. _`MySQL`: https://dev.mysql.com/downloads/connector/python/ +.. _`PostgreSQL`: http://initd.org/psycopg/ +.. _`Oracle DB`: https://pypi.org/project/cx_Oracle/ +.. _`Microsoft SQL Server`: https://pypi.org/project/pyodbc/ + +Other Databases +............... + +* `Memcached`_ is free and open source, high-performance, distributed memory + object caching system, generic in nature, but intended for use in speeding up + dynamic web applications by alleviating database load. +* `Redis`_ is an open source, in-memory data structure store, used as a + database, cache and message broker. It supports data structures such as + strings, hashes, lists, sets, sorted sets with range queries, and more. +* `MongoDB`_ is a cross-platform document-oriented database program. Classified + as a NoSQL database program, MongoDB uses JSON-like documents with + schema. PyMongo is the recommended way to work with MongoDB from Python. +* `LMDB`_ is a lightning-fast, memory-mapped database. With memory-mapped + files, it has the read performance of a pure in-memory database while + retaining the persistence of standard disk-based databases. +* `BerkeleyDB`_ is a software library intended to provide a high-performance + embedded database for key/value data. Berkeley DB is a programmatic toolkit + that provides built-in database support for desktop and server applications. +* `LevelDB`_ is a fast key-value storage library written at Google that + provides an ordered mapping from string keys to string values. Data is stored + sorted by key and users can provide a custom comparison function. + +.. _`Memcached`: https://pypi.org/project/python-memcached/ +.. _`MongoDB`: https://api.mongodb.com/python/current/ +.. _`Redis`: https://redis.io/clients#python +.. _`LMDB`: https://lmdb.readthedocs.io/ +.. _`BerkeleyDB`: https://pypi.org/project/bsddb3/ +.. _`LevelDB`: https://plyvel.readthedocs.io/ + Reference --------- diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 998ebea..1a8e2a0 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -3,6 +3,11 @@ table { width: 100%; } +#comparison table { + display: block; + overflow: scroll; +} + th.head { text-align: center; } diff --git a/tests/timings.py b/tests/timings.py new file mode 100644 index 0000000..d1459e7 --- /dev/null +++ b/tests/timings.py @@ -0,0 +1,48 @@ +import dbm +import diskcache +import pickledb +import shelve +import sqlitedict +import timeit + +value = 'value' + +print('diskcache set') +dc = diskcache.FanoutCache('/tmp/diskcache') +%timeit -n 100 -r 7 dc['key'] = value +print('diskcache get') +%timeit -n 100 -r 7 dc['key'] +print('diskcache set/delete') +%timeit -n 100 -r 7 dc['key'] = value; del dc['key'] + +print('dbm set') +d = dbm.open('/tmp/dbm', 'c') +%timeit -n 100 -r 7 d['key'] = value; d.sync() +print('dbm get') +%timeit -n 100 -r 7 d['key'] +print('dbm set/delete') +%timeit -n 100 -r 7 d['key'] = value; del d['key']; d.sync() + +print('shelve set') +s = shelve.open('/tmp/shelve') +%timeit -n 100 -r 7 s['key'] = value; s.sync() +print('shelve get') +%timeit -n 100 -r 7 s['key'] +print('shelve set/delete') +%timeit -n 100 -r 7 s['key'] = value; del s['key']; s.sync() + +print('sqlitedict set') +sd = sqlitedict.SqliteDict('/tmp/sqlitedict', autocommit=True) +%timeit -n 100 -r 7 sd['key'] = value +print('sqlitedict get') +%timeit -n 100 -r 7 sd['key'] +print('sqlitedict set/delete') +%timeit -n 100 -r 7 sd['key'] = value; del sd['key'] + +print('pickledb set') +p = pickledb.load('/tmp/pickledb', True) +%timeit -n 100 -r 7 p['key'] = value +print('pickledb get') +%timeit -n 100 -r 7 p = pickledb.load('/tmp/pickledb', True); p['key'] +print('pickledb set/delete') +%timeit -n 100 -r 7 p['key'] = value; del p['key'] From 05aa1000f7103705ef4ec1c7dd3d1b670628a39f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 13:50:41 -0700 Subject: [PATCH 120/329] Use time.time for Python 2 compatibility --- diskcache/recipes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 8ca2c09..52f606a 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -236,7 +236,7 @@ def __exit__(self, *exc_info): def throttle(cache, count, seconds, name=None, expire=None, tag=None, - time_func=time.monotonic, sleep_func=time.sleep): + time_func=time.time, sleep_func=time.sleep): """Decorator to throttle calls to function. >>> import diskcache, time From 509b0986c3bae7a584a1517c3498819813ca6cfc Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 13:50:57 -0700 Subject: [PATCH 121/329] Open README in utf-8 for Windows --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 64da622..90dc280 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +from io import open from setuptools import setup from setuptools.command.test import test as TestCommand @@ -15,7 +16,7 @@ def run_tests(self): exit(errno) -with open('README.rst') as reader: +with open('README.rst', encoding='utf-8') as reader: readme = reader.read() setup( From 48f2c5a088b445ec885a0937a9483947933774cd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 13:51:13 -0700 Subject: [PATCH 122/329] Remove unused imports for pylint --- diskcache/djangocache.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index fbafeaa..d171408 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -1,9 +1,6 @@ "Django-compatible disk and file backed cache." from functools import wraps -from math import log -from random import random -from time import time from django.core.cache.backends.base import BaseCache try: From 012f311653c107d76c44ab60cb4bac7debc49034 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 14:02:08 -0700 Subject: [PATCH 123/329] Add testimonials for #110 --- README.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.rst b/README.rst index 6aad03c..f1cac23 100644 --- a/README.rst +++ b/README.rst @@ -46,9 +46,24 @@ testing has 100% coverage with unit tests and hours of stress. Testimonials ------------ +`Daren Hasenkamp`_, Founder -- + + "It's a useful, simple API, just like I love about Redis. It has reduced + the amount of queries hitting my Elasticsearch cluster by over 25% for a + website that gets over a million users/day (100+ hits/second)." + +`Mathias Petermann`_, Senior Linux System Engineer -- + + "I implemented it into a wrapper for our Ansible lookup modules and we were + able to speed up some Ansible runs by almost 3 times. DiskCache is saving + us a ton of time." + Does your company or website use `DiskCache`_? Send us a `message `_ and let us know. +.. _`Daren Hasenkamp`: https://www.linkedin.com/in/daren-hasenkamp-93006438/ +.. _`Mathias Petermann`: https://www.linkedin.com/in/mathias-petermann-a8aa273b/ + Features -------- From 5e69d5b66e52373e9b5fbe4a51eb8811bb766df7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 15:23:19 -0700 Subject: [PATCH 124/329] Add comment blocks for Python 2/3 shims --- diskcache/core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index 10b8e0b..89c83a0 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -19,6 +19,10 @@ import warnings import zlib +############################################################################ +# BEGIN Python 2/3 Shims +############################################################################ + if sys.hexversion < 0x03000000: import cPickle as pickle # pylint: disable=import-error # ISSUE #25 Fix for http://bugs.python.org/issue10211 @@ -48,6 +52,10 @@ def full_name(func): name = func.__name__ return func.__module__ + '.' + name +############################################################################ +# END Python 2/3 Shims +############################################################################ + try: WindowsError except NameError: From c8f125d93af209743a39bf71ad15b6e471bfdcb8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 15:23:34 -0700 Subject: [PATCH 125/329] Add FanoutCache.memoize using monkey-patching for Python 2 --- diskcache/fanout.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index dc31593..a520f25 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -355,9 +355,6 @@ def __delitem__(self, key): del shard[key] - memoize = Cache.memoize - - def check(self, fix=False, retry=False): """Check database and file system consistency. @@ -656,3 +653,21 @@ def index(self, name): temp = Index(directory) _indexes[name] = temp return temp + + +############################################################################ +# BEGIN Python 2/3 Shims +############################################################################ + +import sys # pylint: disable=wrong-import-position,wrong-import-order + +if sys.hexversion < 0x03000000: + import types + memoize_func = Cache.__dict__['memoize'] # pylint: disable=invalid-name + FanoutCache.memoize = types.MethodType(memoize_func, None, FanoutCache) +else: + FanoutCache.memoize = Cache.memoize + +############################################################################ +# END Python 2/3 Shims +############################################################################ From 54744b946263d410372020bfe8c86b6c26e701aa Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 15:41:15 -0700 Subject: [PATCH 126/329] Simplify recipes and make more robust --- diskcache/recipes.py | 42 ++++++++++-------------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 52f606a..53cc589 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -1,31 +1,5 @@ """Disk Cache Recipes ->>> import diskcache as dc, time ->>> cache = dc.Cache() ->>> @cache.memoize(expire=0) -... @barrier(cache, dc.Lock) -... @cache.memoize() -... def work(num): -... time.sleep(1) -... return num ->>> from concurrent.futures import ThreadPoolExecutor ->>> with ThreadPoolExecutor(5) as executor: -... start = time.time() -... nums = list(executor.map(work, range(5))) -... end = time.time() ->>> nums -[0, 1, 2, 3, 4] ->>> int(end - start) -5 ->>> with ThreadPoolExecutor(5) as executor: -... start = time.time() -... nums = list(executor.map(work, range(5))) -... end = time.time() ->>> nums -[0, 1, 2, 3, 4] ->>> int(end - start) -0 - """ import functools @@ -241,12 +215,16 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, >>> import diskcache, time >>> cache = diskcache.Cache() - >>> @throttle(cache, 1, 1) - ... def int_time(): - ... return int(time.time()) - >>> times = [int_time() for _ in range(4)] - >>> [times[i] - times[i - 1] for i in range(1, 4)] - [1, 1, 1] + >>> count = 0 + >>> @throttle(cache, 5, 1) + ... def increment(): + ... global count + ... count += 1 + >>> start = time.time() + >>> while (time.time() - start) <= 4: + ... increment() + >>> count + 25 """ def decorator(func): From 912f96c6895e48f78e6e4d9020aeedac02fd4d35 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 12 Jun 2019 15:45:52 -0700 Subject: [PATCH 127/329] Close Cache before removing directory --- docs/tutorial.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8a67ab8..f2db508 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -311,6 +311,8 @@ The third is :meth:`check ` which verifies cache consistency. It can also fix inconsistencies and reclaim unused space. The return value is a list of warnings. + >>> _ = cache.check(fix=True) + >>> cache.close() >>> import shutil >>> shutil.rmtree(cache.directory) From a60b54108ddd6c71f3cbc0fa83bc03c7429a35bf Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 09:59:26 -0700 Subject: [PATCH 128/329] Suppress PermissionError for Windows wonkiness --- docs/tutorial.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index f2db508..5ce890d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -311,10 +311,11 @@ The third is :meth:`check ` which verifies cache consistency. It can also fix inconsistencies and reclaim unused space. The return value is a list of warnings. - >>> _ = cache.check(fix=True) + >>> warnings = cache.check() >>> cache.close() - >>> import shutil - >>> shutil.rmtree(cache.directory) + >>> import contextlib, shutil + >>> with contextlib.suppress(PermissionError): # Windows wonkiness + ... shutil.rmtree(cache.directory) .. _tutorial-fanoutcache: From 56927b51098395cb1ff9c45a4fbfb5d109bb8806 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 10:13:13 -0700 Subject: [PATCH 129/329] Update DjangoCache tests with touch() test cases --- tests/test_djangocache.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index ea163bc..f70b87c 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -430,6 +430,11 @@ def test_forever_timeout(self): self.assertEqual(cache.get('key3'), 'sausage') self.assertEqual(cache.get('key4'), 'lobster bisque') + cache.set('key5', 'belgian fries', timeout=1) + cache.touch('key5', timeout=None) + time.sleep(2) + self.assertEqual(cache.get('key5'), 'belgian fries') + def test_zero_timeout(self): """ Passing in zero into timeout results in a value that is not cached @@ -444,6 +449,10 @@ def test_zero_timeout(self): self.assertIsNone(cache.get('key3')) self.assertIsNone(cache.get('key4')) + cache.set('key5', 'belgian fries', timeout=5) + cache.touch('key5', timeout=0) + self.assertIsNone(cache.get('key5')) + def test_float_timeout(self): # Make sure a timeout given as a float doesn't crash anything. cache.set("key1", "spam", 100.2) From bce5a77b607781b30702a22b31730604e08fcda4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 10:38:50 -0700 Subject: [PATCH 130/329] Fix recipes doctest for Python 2 support: get_ident and ThreadPool --- diskcache/recipes.py | 36 +++++++++++++++++++++++++++--------- tests/test_doctest.py | 10 ++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 53cc589..fbd5fa8 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -6,11 +6,25 @@ import math import os import random +import sys import threading import time from .core import ENOVAL, args_to_key, full_name +############################################################################ +# BEGIN Python 2/3 Shims +############################################################################ + +if sys.hexversion < 0x03000000: + from thread import get_ident +else: + from threading import get_ident + +############################################################################ +# END Python 2/3 Shims +############################################################################ + class Averager(object): """Recipe for calculating a running average. @@ -120,7 +134,7 @@ def __init__(self, cache, key, expire=None, tag=None): self._expire = expire self._tag = tag pid = os.getpid() - tid = threading.get_ident() + tid = get_ident() self._value = '{}-{}'.format(pid, tid) def acquire(self): @@ -221,10 +235,10 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, ... global count ... count += 1 >>> start = time.time() - >>> while (time.time() - start) <= 4: + >>> while (time.time() - start) <= 2: ... increment() >>> count - 25 + 15 """ def decorator(func): @@ -280,13 +294,17 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): >>> cache = diskcache.Cache() >>> @barrier(cache, Lock) ... def work(num): + ... print('worker started') ... time.sleep(1) - ... return int(time.time()) - >>> from concurrent.futures import ThreadPoolExecutor - >>> with ThreadPoolExecutor(4) as executor: - ... times = sorted(executor.map(work, range(4))) - >>> [times[i] - times[i - 1] for i in range(1, 4)] - [1, 1, 1] + ... print('worker finished') + >>> import multiprocessing.pool + >>> pool = multiprocessing.pool.ThreadPool(2) + >>> _ = pool.map(work, range(2)) + worker started + worker finished + worker started + worker finished + >>> pool.terminate() """ def decorator(func): diff --git a/tests/test_doctest.py b/tests/test_doctest.py index e398391..981d00c 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -29,15 +29,13 @@ def test_persistent(): assert failures == 0 -def test_tutorial(): - if sys.hexversion < 0x03000000: - return - failures, _ = doctest.testfile('../docs/tutorial.rst') +def test_recipes(): + failures, _ = doctest.testmod(diskcache.recipes) assert failures == 0 -def test_recipes(): +def test_tutorial(): if sys.hexversion < 0x03000000: return - failures, _ = doctest.testmod(diskcache.recipes) + failures, _ = doctest.testfile('../docs/tutorial.rst') assert failures == 0 From 8701cf901e07f145d5307eb8c9a9dd0cb6fb8e65 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 10:54:32 -0700 Subject: [PATCH 131/329] Update tutorial doctests to work on Python 2/3 --- docs/tutorial.rst | 64 ++++++++++++++++++++++++------------------- tests/test_doctest.py | 2 -- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 5ce890d..deea9cc 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -92,26 +92,26 @@ Closed Cache objects will automatically re-open when accessed. But opening Cache objects is relatively slow, and since all operations are atomic, you can safely leave Cache objects open. - >>> cache.set(b'key', b'value') + >>> cache.set('key', 'value') True >>> cache.close() - >>> cache.get(b'key') # Automatically opens, but slower. - b'value' + >>> cache.get('key') # Automatically opens, but slower. + 'value' Set an item, get a value, and delete a key using the usual operators: - >>> cache[b'key'] = b'value' - >>> cache[b'key'] - b'value' - >>> b'key' in cache + >>> cache['key'] = 'value' + >>> cache['key'] + 'value' + >>> 'key' in cache True - >>> del cache[b'key'] + >>> del cache['key'] There's also a :meth:`set ` method with additional keyword parameters: `expire`, `read`, and `tag`. >>> from io import BytesIO - >>> cache.set(b'key', BytesIO(b'value'), expire=5, read=True, tag='data') + >>> cache.set('key', BytesIO(b'value'), expire=5, read=True, tag='data') True In the example above: the key expires in 5 seconds, the value is read as a @@ -119,14 +119,14 @@ file-like object, and tag metadata is stored with the key. Another method, :meth:`get ` supports querying extra information with `default`, `read`, `expire_time`, and `tag` keyword parameters. - >>> result = cache.get(b'key', default=b'', read=True, expire_time=True, tag=True) + >>> result = cache.get('key', read=True, expire_time=True, tag=True) >>> reader, timestamp, tag = result - >>> type(reader) - - >>> type(timestamp) - - >>> tag - 'data' + >>> print(reader.read().decode()) + value + >>> type(timestamp).__name__ + 'float' + >>> print(tag) + data The return value is a tuple containing the value, expire time (seconds from epoch), and tag. Because we passed ``read=True`` the value is returned as a @@ -180,8 +180,14 @@ used to delete an item in the cache and return its value. 'does not exist' >>> cache.set('dave', 0, expire=None, tag='admin') True - >>> cache.pop('dave', expire_time=True, tag=True) - (0, None, 'admin') + >>> result = cache.pop('dave', expire_time=True, tag=True) + >>> value, timestamp, tag = result + >>> value + 0 + >>> print(timestamp) + None + >>> print(tag) + admin The :meth:`pop ` operation is atomic and using :meth:`incr ` together is an accurate method for counting and dumping @@ -313,9 +319,11 @@ return value is a list of warnings. >>> warnings = cache.check() >>> cache.close() - >>> import contextlib, shutil - >>> with contextlib.suppress(PermissionError): # Windows wonkiness + >>> import shutil + >>> try: ... shutil.rmtree(cache.directory) + ... except PermissionError: # Windows wonkiness + ... pass .. _tutorial-fanoutcache: @@ -475,8 +483,8 @@ access and editing at both front and back sides. :class:`Deque >>> deque.appendleft('foo') >>> len(deque) 4 - >>> type(deque.directory) - + >>> type(deque.directory).__name__ + 'str' >>> other = Deque(directory=deque.directory) >>> len(other) 4 @@ -619,13 +627,13 @@ All clients accessing the cache are expected to use the same eviction policy. The policy can be set during initialization using a keyword argument. >>> cache = Cache() - >>> cache.eviction_policy - 'least-recently-stored' + >>> print(cache.eviction_policy) + least-recently-stored >>> cache = Cache(eviction_policy='least-frequently-used') - >>> cache.eviction_policy - 'least-frequently-used' - >>> cache.reset('eviction_policy', 'least-recently-used') - 'least-recently-used' + >>> print(cache.eviction_policy) + least-frequently-used + >>> print(cache.reset('eviction_policy', 'least-recently-used')) + least-recently-used Though the eviction policy is changed, the previously created indexes will not be dropped. Prefer to always specify the eviction policy as a keyword argument diff --git a/tests/test_doctest.py b/tests/test_doctest.py index 981d00c..70fa61c 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -35,7 +35,5 @@ def test_recipes(): def test_tutorial(): - if sys.hexversion < 0x03000000: - return failures, _ = doctest.testfile('../docs/tutorial.rst') assert failures == 0 From f5422b4f774bfe98510b120c007b72816064b407 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 11:05:01 -0700 Subject: [PATCH 132/329] Ignore old long-integer "L"-suffix --- docs/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index deea9cc..aabf9ff 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -561,7 +561,7 @@ are updated lazily. Prefer idioms like :meth:`len ` directly. >>> cache = Cache(size_limit=int(4e9)) - >>> cache.size_limit + >>> print(cache.size_limit) 4000000000 >>> cache.disk_min_file_size 32768 From 00dced81389745759b455e813ff7f41a6c6e1f54 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 11:05:36 -0700 Subject: [PATCH 133/329] Change PermissionError to OSError for Python 2 --- docs/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index aabf9ff..d09fa3b 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -322,7 +322,7 @@ return value is a list of warnings. >>> import shutil >>> try: ... shutil.rmtree(cache.directory) - ... except PermissionError: # Windows wonkiness + ... except OSError: # Windows wonkiness ... pass .. _tutorial-fanoutcache: From 03a5f67de75d925f6c2bf4ac3f8c78fad94549e6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 11:05:54 -0700 Subject: [PATCH 134/329] Decrease throttle rate for clock skew on AppVeyor --- diskcache/recipes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index fbd5fa8..90ca376 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -230,7 +230,7 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, >>> import diskcache, time >>> cache = diskcache.Cache() >>> count = 0 - >>> @throttle(cache, 5, 1) + >>> @throttle(cache, 2, 1) ... def increment(): ... global count ... count += 1 @@ -238,7 +238,7 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, >>> while (time.time() - start) <= 2: ... increment() >>> count - 15 + 6 """ def decorator(func): From 665683c2f35021ee4b03146cc75112012634c1e2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 11:35:14 -0700 Subject: [PATCH 135/329] Disable pylint import-error for Python 2 import --- diskcache/recipes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 90ca376..43edf4b 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -17,7 +17,7 @@ ############################################################################ if sys.hexversion < 0x03000000: - from thread import get_ident + from thread import get_ident # pylint: disable=import-error else: from threading import get_ident From 325a525145fb511aad8b6c570c44a51af2c2774d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 11:51:39 -0700 Subject: [PATCH 136/329] Use full_name() for throttle key and accept 6 or 7 calls --- diskcache/recipes.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 43edf4b..3223096 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -230,30 +230,20 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, >>> import diskcache, time >>> cache = diskcache.Cache() >>> count = 0 - >>> @throttle(cache, 2, 1) + >>> @throttle(cache, 2, 1) # 2 calls per 1 second ... def increment(): ... global count ... count += 1 >>> start = time.time() >>> while (time.time() - start) <= 2: ... increment() - >>> count - 6 + >>> count in (6, 7) # 6 or 7 calls depending on processor load + True """ def decorator(func): rate = count / float(seconds) - - if name is None: - try: - key = func.__qualname__ - except AttributeError: - key = func.__name__ - - key = func.__module__ + '.' + key - else: - key = name - + key = full_name(func) if name is None else name now = time_func() cache.set(key, (now, count), expire=expire, tag=tag, retry=True) From 2be5bac6cf9a640829bd8e75ace8f5ccbc24c012 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 12:00:45 -0700 Subject: [PATCH 137/329] Update docstrings regarding Timeout errors --- diskcache/djangocache.py | 2 +- diskcache/fanout.py | 3 +-- diskcache/persistent.py | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index d171408..a2d863c 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -295,7 +295,7 @@ def stats(self, enable=True, reset=False): def create_tag_index(self): """Create tag index on cache database. - It is better to initialize cache with `tag_index=True` than use this. + Better to initialize cache with `tag_index=True` than use this. :raises Timeout: if database timeout occurs diff --git a/diskcache/fanout.py b/diskcache/fanout.py index a520f25..d3cd413 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -395,7 +395,7 @@ def expire(self, retry=False): def create_tag_index(self): """Create tag index on cache database. - It is better to initialize cache with `tag_index=True` than use this. + Better to initialize cache with `tag_index=True` than use this. :raises Timeout: if database timeout occurs @@ -550,7 +550,6 @@ def reset(self, key, value=ENOVAL): :param str key: Settings key for item :param value: value for item (optional) :return: updated value for item - :raises Timeout: if database timeout occurs """ for shard in self._shards: diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 5f2dbde..5350fbf 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -648,7 +648,6 @@ def transact(self): [4, 0, 1, 2, 3] :return: context manager for use in `with` statement - :raises Timeout: if database timeout occurs """ with self._cache.transact(retry=True): @@ -1385,7 +1384,6 @@ def transact(self): 123.4 :return: context manager for use in `with` statement - :raises Timeout: if database timeout occurs """ with self._cache.transact(retry=True): From a1feac48c11bb9f199324298a7b91c1f2a44a6ec Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 13 Jun 2019 16:09:00 -0700 Subject: [PATCH 138/329] Add caveat about full disk --- docs/tutorial.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index d09fa3b..1d70e55 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -711,6 +711,12 @@ this reason, :doc:`DiskCache ` currently `performs poorly`_ on `Python Anywhere`_. Users have also reported issues running inside of `Parallels`_ shared folders. +:doc:`DiskCache ` uses transactions when writing data to disk using +SQLite. When the disk or database is full, a :exc:`sqlite3.OperationalError` +will be raised from any method that attempts to write data. Read operations +will still succeed so long as they do not cause any write (as might occur if +cache statistics are being recorded). + .. _`hash protocol`: https://docs.python.org/library/functions.html#hash .. _`not recommended`: https://www.sqlite.org/faq.html#q5 .. _`performs poorly`: https://www.pythonanywhere.com/forums/topic/1847/ From f3866d21b74d506bf86bf83d480c4e826a820162 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:39:45 -0700 Subject: [PATCH 139/329] Audit and add retry=True as needed to recipes --- diskcache/recipes.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 3223096..b6ac6ac 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -52,7 +52,7 @@ def __init__(self, cache, key, expire=None, tag=None): def add(self, value): "Add `value` to average." - with self._cache.transact(): + with self._cache.transact(retry=True): total, count = self._cache.get(self._key, default=(0.0, 0)) total += value count += 1 @@ -140,7 +140,7 @@ def __init__(self, cache, key, expire=None, tag=None): def acquire(self): "Acquire lock by incrementing count using spin-lock algorithm." while True: - with self._cache.transact(): + with self._cache.transact(retry=True): value, count = self._cache.get(self._key, default=(None, 0)) if self._value == value or count == 0: self._cache.set( @@ -152,7 +152,7 @@ def acquire(self): def release(self): "Release lock by decrementing count." - with self._cache.transact(): + with self._cache.transact(retry=True): value, count = self._cache.get(self._key, default=(None, 0)) is_owned = self._value == value and count > 0 assert is_owned, 'cannot release un-acquired lock' @@ -196,7 +196,7 @@ def __init__(self, cache, key, value=1, expire=None, tag=None): def acquire(self): "Acquire semaphore by decrementing value using spin-lock algorithm." while True: - with self._cache.transact(): + with self._cache.transact(retry=True): value = self._cache.get(self._key, default=self._value) if value > 0: self._cache.set( @@ -208,7 +208,7 @@ def acquire(self): def release(self): "Release semaphore by incrementing value." - with self._cache.transact(): + with self._cache.transact(retry=True): value = self._cache.get(self._key, default=self._value) assert self._value > value, 'cannot release un-acquired semaphore' value += 1 @@ -250,16 +250,16 @@ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): while True: - with cache.transact(): - last, tally = cache.get(key, retry=True) + with cache.transact(retry=True): + last, tally = cache.get(key) now = time_func() tally += (now - last) * rate delay = 0 if tally > count: - cache.set(key, (now, count - 1), expire, retry=True) + cache.set(key, (now, count - 1), expire) elif tally >= 1: - cache.set(key, (now, tally - 1), expire, retry=True) + cache.set(key, (now, tally - 1), expire) else: delay = (1 - tally) / rate @@ -399,7 +399,9 @@ def recompute(): # Check whether a thread has started for early recomputation. thread_key = key + (ENOVAL,) - thread_added = cache.add(thread_key, None, expire=delta) + thread_added = cache.add( + thread_key, None, expire=delta, retry=True, + ) if thread_added: # Start thread for early recomputation. From c61a7c6fa32a0010385a13767d1bbfe6c4c345fe Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:41:05 -0700 Subject: [PATCH 140/329] Fix formatting for keyword arguments --- diskcache/recipes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index b6ac6ac..b23be1b 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -157,8 +157,8 @@ def release(self): is_owned = self._value == value and count > 0 assert is_owned, 'cannot release un-acquired lock' self._cache.set( - self._key, (value, count - 1), expire=self._expire, - tag=self._tag, + self._key, (value, count - 1), + expire=self._expire, tag=self._tag, ) def __enter__(self): @@ -200,8 +200,8 @@ def acquire(self): value = self._cache.get(self._key, default=self._value) if value > 0: self._cache.set( - self._key, value - 1, expire=self._expire, - tag=self._tag, + self._key, value - 1, + expire=self._expire, tag=self._tag, ) return time.sleep(0.001) From 24a6299b4a8bd03d84cc0ff4d85d23c4440e9d09 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:41:26 -0700 Subject: [PATCH 141/329] Group Python 2/3 shims together --- diskcache/fanout.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index d3cd413..085345f 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -4,17 +4,24 @@ import operator import os.path as op import sqlite3 +import sys import tempfile import time -try: - from functools import reduce -except ImportError: - reduce # pylint: disable=pointless-statement - from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout from .persistent import Deque, Index +############################################################################ +# BEGIN Python 2/3 Shims +############################################################################ + +if sys.hexversion >= 0x03000000: + from functools import reduce + +############################################################################ +# END Python 2/3 Shims +############################################################################ + class FanoutCache(object): "Cache that shards keys and values." From 7c9b6f73e67d4a288d6ca8d6c92e7c6a4db7163b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:42:07 -0700 Subject: [PATCH 142/329] Doctest and formatting fixes --- diskcache/persistent.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 5350fbf..bff2df0 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -173,7 +173,6 @@ def _index(self, index, func): raise IndexError('deque index out of range') - def __getitem__(self, index): """deque.__getitem__(index) <==> deque[index] @@ -360,6 +359,13 @@ def appendleft(self, value): def clear(self): """Remove all elements from deque. + >>> deque = Deque('abc') + >>> len(deque) + 3 + >>> deque.clear() + >>> list(deque) + [] + """ self._cache.clear(retry=True) @@ -902,7 +908,7 @@ def popitem(self, last=True): >>> index.popitem() Traceback (most recent call last): ... - KeyError + KeyError: 'dictionary is empty' :param bool last: pop last item pair (default True) :return: key and value item pair @@ -1005,6 +1011,13 @@ def pull(self, prefix=None, default=(None, None), side='front'): def clear(self): """Remove all items from index. + >>> index = Index({'a': 0, 'b': 1, 'c': 2}) + >>> len(index) + 3 + >>> index.clear() + >>> dict(index) + {} + """ self._cache.clear(retry=True) From c66f0b21214cda18bf6d4b62e85351a3f5cc984c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:42:55 -0700 Subject: [PATCH 143/329] Change Index.popitem to use Cache.peekitem and transaction --- diskcache/persistent.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index bff2df0..961f773 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -918,21 +918,11 @@ def popitem(self, last=True): # pylint: disable=arguments-differ _cache = self._cache - while True: - try: - if last: - key = next(reversed(_cache)) - else: - key = next(iter(_cache)) - except StopIteration: - raise KeyError + with _cache.transact(retry=True): + key, value = _cache.peekitem(last=last) + del _cache[key] - try: - value = _cache.pop(key, retry=True) - except KeyError: - continue - else: - return key, value + return key, value def push(self, value, prefix=None, side='back'): From 892b53e186abe479fa36446f065fc2ec1014d988 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 11:43:34 -0700 Subject: [PATCH 144/329] Add tests for Index and Deque coverage --- tests/test_deque.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_index.py | 4 ++++ 2 files changed, 61 insertions(+) diff --git a/tests/test_deque.py b/tests/test_deque.py index a3ac018..640cee2 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -76,12 +76,56 @@ def test_getsetdel(deque): assert len(deque) == 0 +def test_index_positive(deque): + cache = mock.MagicMock() + cache.__len__.return_value = 3 + cache.iterkeys.return_value = ['a', 'b', 'c'] + cache.__getitem__.side_effect = [KeyError, 101, 102] + with mock.patch.object(deque, '_cache', cache): + assert deque[0] == 101 + + +def test_index_negative(deque): + cache = mock.MagicMock() + cache.__len__.return_value = 3 + cache.iterkeys.return_value = ['c', 'b', 'a'] + cache.__getitem__.side_effect = [KeyError, 101, 100] + with mock.patch.object(deque, '_cache', cache): + assert deque[-1] == 101 + + +def test_index_out_of_range(deque): + cache = mock.MagicMock() + cache.__len__.return_value = 3 + cache.iterkeys.return_value = ['a', 'b', 'c'] + cache.__getitem__.side_effect = [KeyError] * 3 + with mock.patch.object(deque, '_cache', cache): + with pytest.raises(IndexError): + deque[0] + + +def test_iter_keyerror(deque): + cache = mock.MagicMock() + cache.iterkeys.return_value = ['a', 'b', 'c'] + cache.__getitem__.side_effect = [KeyError, 101, 102] + with mock.patch.object(deque, '_cache', cache): + assert list(iter(deque)) == [101, 102] + + def test_reversed(deque): sequence = list('abcde') deque += sequence assert list(reversed(deque)) == list(reversed(sequence)) +def test_reversed_keyerror(deque): + cache = mock.MagicMock() + cache.iterkeys.return_value = ['c', 'b', 'a'] + cache.__getitem__.side_effect = [KeyError, 101, 100] + with mock.patch.object(deque, '_cache', cache): + assert list(reversed(deque)) == [101, 100] + + def test_state(deque): sequence = list('abcde') deque.extend(sequence) @@ -178,6 +222,15 @@ def test_remove_valueerror(deque): deque.remove(0) +def test_remove_keyerror(deque): + cache = mock.MagicMock() + cache.iterkeys.return_value = ['a', 'b', 'c'] + cache.__getitem__.side_effect = [KeyError, 100, 100] + cache.__delitem__.side_effect = [KeyError, None] + with mock.patch.object(deque, '_cache', cache): + deque.remove(100) + + def test_reverse(deque): deque += 'abcde' deque.reverse() @@ -223,3 +276,7 @@ def test_rotate_indexerror_negative(deque): with mock.patch.object(deque, '_cache', cache): deque.rotate(-1) + + +def test_repr(deque): + assert repr(deque).startswith('Deque(') diff --git a/tests/test_index.py b/tests/test_index.py index 83ba5cf..f4232c8 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -179,3 +179,7 @@ def fibrec(num): assert hits2 == (hits1 + count) assert misses2 == misses1 + + +def test_repr(index): + assert repr(index).startswith('Index(') From 0bf521c6a6cf2268fa4856b454b95e48ea10ec82 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:08:49 -0700 Subject: [PATCH 145/329] Rename test_early_recompute.py to plot_early_recompute.py --- tests/{test_early_recompute.py => plot_early_recompute.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_early_recompute.py => plot_early_recompute.py} (100%) diff --git a/tests/test_early_recompute.py b/tests/plot_early_recompute.py similarity index 100% rename from tests/test_early_recompute.py rename to tests/plot_early_recompute.py From 13a59228f2c918d200acfeafa703b7805dcd9d0a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:09:10 -0700 Subject: [PATCH 146/329] Add test cases for recipes --- tests/test_recipes.py | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_recipes.py diff --git a/tests/test_recipes.py b/tests/test_recipes.py new file mode 100644 index 0000000..35de332 --- /dev/null +++ b/tests/test_recipes.py @@ -0,0 +1,78 @@ +"Test diskcache.recipes." + +import diskcache as dc +import mock +import pytest +import shutil +import threading +import time + + +@pytest.fixture +def cache(): + with dc.Cache() as cache: + yield cache + shutil.rmtree(cache.directory, ignore_errors=True) + + +def test_averager(cache): + nums = dc.Averager(cache, 'nums') + for i in range(10): + nums.add(i) + assert nums.get() == 4.5 + assert nums.pop() == 4.5 + for i in range(20): + nums.add(i) + assert nums.get() == 9.5 + assert nums.pop() == 9.5 + + +def test_rlock(cache): + state = {'num': 0} + rlock = dc.RLock(cache, 'demo') + def worker(): + state['num'] += 1 + with rlock: + state['num'] += 1 + time.sleep(0.1) + with rlock: + thread = threading.Thread(target=worker) + thread.start() + time.sleep(0.1) + assert state['num'] == 1 + thread.join() + assert state['num'] == 2 + + +def test_semaphore(cache): + state = {'num': 0} + semaphore = dc.BoundedSemaphore(cache, 'demo', value=3) + def worker(): + state['num'] += 1 + with semaphore: + state['num'] += 1 + time.sleep(0.1) + semaphore.acquire() + semaphore.acquire() + with semaphore: + thread = threading.Thread(target=worker) + thread.start() + time.sleep(0.1) + assert state['num'] == 1 + thread.join() + assert state['num'] == 2 + semaphore.release() + semaphore.release() + + +def test_memoize_stampede(cache): + state = {'num': 0} + @dc.memoize_stampede(cache, 0.1) + def worker(num): + time.sleep(0.01) + state['num'] += 1 + return num + start = time.time() + while (time.time() - start) < 1: + worker(100) + assert state['num'] > 0 From bb750fbf629c1d93f0f0ac7bab931669ec45a98c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:09:24 -0700 Subject: [PATCH 147/329] Create connection in __enter__ --- diskcache/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/diskcache/core.py b/diskcache/core.py index 89c83a0..3c0b023 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -2332,6 +2332,7 @@ def close(self): def __enter__(self): + connection = self._con # Create connection in thread. return self From fb1bc6738a9cf1510e08bdc3db092730c2d9df44 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:10:03 -0700 Subject: [PATCH 148/329] Fix rlock value calculation (must be function local) --- diskcache/recipes.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index b23be1b..069269e 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -133,18 +133,19 @@ def __init__(self, cache, key, expire=None, tag=None): self._key = key self._expire = expire self._tag = tag - pid = os.getpid() - tid = get_ident() - self._value = '{}-{}'.format(pid, tid) def acquire(self): "Acquire lock by incrementing count using spin-lock algorithm." + pid = os.getpid() + tid = get_ident() + pid_tid = '{}-{}'.format(pid, tid) + while True: with self._cache.transact(retry=True): value, count = self._cache.get(self._key, default=(None, 0)) - if self._value == value or count == 0: + if pid_tid == value or count == 0: self._cache.set( - self._key, (self._value, count + 1), + self._key, (pid_tid, count + 1), expire=self._expire, tag=self._tag, ) return @@ -152,9 +153,13 @@ def acquire(self): def release(self): "Release lock by decrementing count." + pid = os.getpid() + tid = get_ident() + pid_tid = '{}-{}'.format(pid, tid) + with self._cache.transact(retry=True): value, count = self._cache.get(self._key, default=(None, 0)) - is_owned = self._value == value and count > 0 + is_owned = pid_tid == value and count > 0 assert is_owned, 'cannot release un-acquired lock' self._cache.set( self._key, (value, count - 1), From ac3a3b619c0e5b6eaca429e93cd0d3678fb1ec0c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:10:37 -0700 Subject: [PATCH 149/329] Refactor early recomputation to close cache in thread --- diskcache/recipes.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 069269e..48b8241 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -377,6 +377,13 @@ def decorator(func): "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." + start = time.time() + result = func(*args, **kwargs) + delta = time.time() - start + return result, delta + @functools.wraps(func) def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." @@ -385,14 +392,6 @@ def wrapper(*args, **kwargs): key, default=ENOVAL, expire_time=True, retry=True, ) - def recompute(): - start = time.time() - result = func(*args, **kwargs) - delta = time.time() - start - pair = result, delta - cache.set(key, pair, expire=expire, tag=tag, retry=True) - return result - if pair is not ENOVAL: result, delta = pair now = time.time() @@ -410,13 +409,21 @@ def recompute(): if thread_added: # Start thread for early recomputation. + def recompute(): + with cache: + pair = timer(*args, **kwargs) + cache.set( + key, pair, expire=expire, tag=tag, retry=True, + ) thread = threading.Thread(target=recompute) thread.daemon = True thread.start() return result - return recompute() # Cache miss. + pair = timer(*args, **kwargs) + cache.set(key, pair, expire=expire, tag=tag, retry=True) + return pair[0] def __cache_key__(*args, **kwargs): "Make key for cache given function arguments." From 713600acd72d86cabb18d16361ebef27bb72a55d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Jun 2019 13:18:57 -0700 Subject: [PATCH 150/329] Pylint fixes --- diskcache/core.py | 3 ++- diskcache/fanout.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 3c0b023..c74241e 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -2332,7 +2332,8 @@ def close(self): def __enter__(self): - connection = self._con # Create connection in thread. + # Create connection in thread. + connection = self._con # pylint: disable=unused-variable return self diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 085345f..8a0a722 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -665,8 +665,6 @@ def index(self, name): # BEGIN Python 2/3 Shims ############################################################################ -import sys # pylint: disable=wrong-import-position,wrong-import-order - if sys.hexversion < 0x03000000: import types memoize_func = Cache.__dict__['memoize'] # pylint: disable=invalid-name From 291a1cfb8ed552cc93a4982f926d581449ef5149 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 19 Jun 2019 17:39:11 -0700 Subject: [PATCH 151/329] Update benchmarks for v4 --- docs/_static/core-p1-delete.png | Bin 45852 -> 44624 bytes docs/_static/core-p1-get.png | Bin 45803 -> 46320 bytes docs/_static/core-p1-set.png | Bin 46824 -> 45292 bytes docs/_static/core-p8-delete.png | Bin 47424 -> 48531 bytes docs/_static/core-p8-get.png | Bin 46972 -> 46379 bytes docs/_static/core-p8-set.png | Bin 47227 -> 48240 bytes docs/_static/djangocache-delete.png | Bin 30412 -> 33750 bytes docs/_static/djangocache-get.png | Bin 33536 -> 29965 bytes docs/_static/djangocache-set.png | Bin 33357 -> 33364 bytes tests/{timings.py => benchmark_kv_store.py} | 0 tests/timings_core_p1.txt | 40 ++++++++++---------- tests/timings_core_p8.txt | 40 ++++++++++---------- tests/timings_djangocache.txt | 40 ++++++++++---------- 13 files changed, 60 insertions(+), 60 deletions(-) rename tests/{timings.py => benchmark_kv_store.py} (100%) diff --git a/docs/_static/core-p1-delete.png b/docs/_static/core-p1-delete.png index 6cf196f17d253df0244b374281281f2a1fb667fb..25907e5c3c59521586661bf0a5ed3cef4937458c 100644 GIT binary patch literal 44624 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 JZDQ;5{{VSWOq~D# literal 45803 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 J($!3S{|n=Q5_bRq diff --git a/docs/_static/core-p8-delete.png b/docs/_static/core-p8-delete.png index f717e0eeef7388d6285ee795a1d092c09205a9e1..c6cb29968237ca40fad85bd4ccab59ecbd961dde 100644 GIT binary patch literal 48531 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 literal 47424 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 diff --git a/docs/_static/core-p8-get.png b/docs/_static/core-p8-get.png index 0c4451b5f5318c5edfe3ca0a793c2a0fd546229e..889c032d38df1bd4bb53f3c0fbdce413759b43cd 100644 GIT binary patch literal 46379 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

gCDD=n$A2lKusJMq^ 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 diff --git a/docs/_static/core-p8-set.png b/docs/_static/core-p8-set.png index 5be3b6cbd0d46c359469ba49baf43466971a30f0..f653fe1bd23e834bef7c62fee5260d8c70861fd8 100644 GIT binary patch literal 48240 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 literal 47227 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 diff --git a/docs/_static/djangocache-delete.png b/docs/_static/djangocache-delete.png index 59d60579d1ed7d7c4c4e05c1d86fbfd4031fc47d..7d4225c1a2b22953d2abc6450e1bb7a4b2f06e76 100644 GIT binary patch literal 33750 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�bmcN7 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 VrFrt#)8s0uISW6?n7v~AzX6S<>stT- literal 30412 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 HQ$iB}T{U8w diff --git a/docs/_static/djangocache-get.png b/docs/_static/djangocache-get.png index e92d4a7b9d6dc2b01636f2d492c94ecdf1470ce5..4805d2558ef3741b17b713fe247057b37e75d201 100644 GIT binary patch literal 29965 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 literal 33536 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 C+|f<| diff --git a/docs/_static/djangocache-set.png b/docs/_static/djangocache-set.png index bd51a62ded3ce07f59076702d91ca83253856536..a2a241a169bdd9b8dc63c32629f3f3d3e5de4166 100644 GIT binary patch literal 33364 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 literal 33357 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 i_WD4B{}n*o?ord-xLHfj;8Ki0H+oK Date: Fri, 21 Jun 2019 16:33:18 -0700 Subject: [PATCH 152/329] Fix tables for v4 docs --- README.rst | 83 +++++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/README.rst b/README.rst index f1cac23..b58e208 100644 --- a/README.rst +++ b/README.rst @@ -67,7 +67,6 @@ Does your company or website use `DiskCache`_? Send us a `message Features -------- -- TODO: update with Comparison below - Pure-Python - Fully Documented - Benchmark comparisons (alternatives, Django cache backends) @@ -161,56 +160,56 @@ other projects are shown in the tables below. **Features** -================ ================ ======= ======= ============ ============ -Feature diskcache dbm shelve sqlitedict pickleDB -================ ================ ======= ======= ============ ============ -Atomic? Always Maybe Maybe Maybe No -Persistent? Yes Yes Yes Yes Yes -Thread-safe? Yes No No Yes No -Process-safe? Yes No No Maybe No -Backend? SQLite DBM DBM SQLite File -Serialization? Customizable None Pickle Customizable JSON -Data Types? Mapping/Deque Mapping Mapping Mapping Mapping -Ordering? Insertion/Sorted None None None None -Eviction? None/LRS/LRU/LFU None None None None -Vacuum? Automatic Maybe Maybe Manual Automatic -Transactions? Yes No No Maybe No -Multiprocessing? Yes No No No No -Forkable? Yes No No No No -Metadata? Yes No No No No -================ ================ ======= ======= ============ ============ +================ ============= ========= ========= ============ ============ +Feature diskcache dbm shelve sqlitedict pickleDB +================ ============= ========= ========= ============ ============ +Atomic? Always Maybe Maybe Maybe No +Persistent? Yes Yes Yes Yes Yes +Thread-safe? Yes No No Yes No +Process-safe? Yes No No Maybe No +Backend? SQLite DBM DBM SQLite File +Serialization? Customizable None Pickle Customizable JSON +Data Types? Mapping/Deque Mapping Mapping Mapping Mapping +Ordering? Insert/Sorted None None None None +Eviction? LRU/LFU/more None None None None +Vacuum? Automatic Maybe Maybe Manual Automatic +Transactions? Yes No No Maybe No +Multiprocessing? Yes No No No No +Forkable? Yes No No No No +Metadata? Yes No No No No +================ ============= ========= ========= ============ ============ **Quality** -================ ================ ======= ======= ============ ============ -Project diskcache dbm shelve sqlitedict pickleDB -================ ================ ======= ======= ============ ============ -Tests? Yes Yes Yes Yes Yes -Coverage? Yes Yes Yes Yes No -Stress? Yes No No No No -CI Tests? Travis/AppVeyor Yes Yes Travis No -Python? 2/3/PyPy All All 2/3 2/3 -License? Apache2 Python Python Apache2 3-Clause BSD -Docs? Extensive Summary Summary Readme Summary -Benchmarks? Yes No No No No -Sources? GitHub GitHub GitHub GitHub GitHub -Pure-Python? Yes Yes Yes Yes Yes -Server? No No No No No -Integrations? Django None None None None -================ ================ ======= ======= ============ ============ +================ ============= ========= ========= ============ ============ +Project diskcache dbm shelve sqlitedict pickleDB +================ ============= ========= ========= ============ ============ +Tests? Yes Yes Yes Yes Yes +Coverage? Yes Yes Yes Yes No +Stress? Yes No No No No +CI Tests? Linux/Windows Yes Yes Linux No +Python? 2/3/PyPy All All 2/3 2/3 +License? Apache2 Python Python Apache2 3-Clause BSD +Docs? Extensive Summary Summary Readme Summary +Benchmarks? Yes No No No No +Sources? GitHub GitHub GitHub GitHub GitHub +Pure-Python? Yes Yes Yes Yes Yes +Server? No No No No No +Integrations? Django None None None None +================ ============= ========= ========= ============ ============ **Timings** These are very rough measurements. See `DiskCache Cache Benchmarks`_ for more rigorous data. -================ ================ ======= ======= ============ ============ -Project diskcache dbm shelve sqlitedict pickleDB -================ ================ ======= ======= ============ ============ -get 25 µs 36 µs 41 µs 513 µs 92 µs -set 198 µs 900 µs 928 µs 697 µs 1,020 µs -delete 248 µs 740 µs 702 µs 1,717 µs 1,020 µs -================ ================ ======= ======= ============ ============ +================ ============= ========= ========= ============ ============ +Project diskcache dbm shelve sqlitedict pickleDB +================ ============= ========= ========= ============ ============ +get 25 µs 36 µs 41 µs 513 µs 92 µs +set 198 µs 900 µs 928 µs 697 µs 1,020 µs +delete 248 µs 740 µs 702 µs 1,717 µs 1,020 µs +================ ============= ========= ========= ============ ============ Caching Libraries ................. From 4be3e397af2bdc9f7e11e359d583afb833f70ca7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 21 Jun 2019 17:36:23 -0700 Subject: [PATCH 153/329] Update quickstart with module, caching, persistence, and recipes sections --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index b58e208..6299ce6 100644 --- a/README.rst +++ b/README.rst @@ -99,14 +99,31 @@ Installing DiskCache is simple with You can access documentation in the interpreter with Python's built-in help function:: + >>> import diskcache + >>> help(diskcache) + +// caching + >>> from diskcache import Cache, FanoutCache, DjangoCache >>> help(Cache) >>> help(FanoutCache) >>> help(DjangoCache) + +// persistence + >>> from diskcache import Deque, Index >>> help(Deque) >>> help(Index) +// recipes + + >>> from diskcache import memoize_stampede, Lock, throttle + >>> help(memoize_stampede) + >>> help(Lock) + >>> help(throttle) + +// tutorial and api are required reading + User Guide ---------- From 99454781f23af5a4cabf03a18ae00b29451b8b89 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 21 Jun 2019 17:36:55 -0700 Subject: [PATCH 154/329] Reformat api page and improve docstrings --- diskcache/__init__.py | 8 ++++++- docs/api.rst | 51 ++++++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index dba3cde..96a5732 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -1,4 +1,10 @@ -"DiskCache: disk and file backed cache." +""" +DiskCache API Reference +======================= + +The :doc:`tutorial` provides a helpful walkthrough of most methods. + +""" from .core import Cache, Disk, EmptyDirWarning, UnknownFileWarning, Timeout from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN diff --git a/docs/api.rst b/docs/api.rst index 660d094..7a6d91e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,19 +1,17 @@ -DiskCache API Reference -======================= - -The :doc:`tutorial` provides a helpful walkthrough of most methods. +.. automodule:: diskcache .. contents:: :local: -DjangoCache ------------ +Cache +----- -Read the :ref:`DjangoCache tutorial ` for example usage. +Read the :ref:`Cache tutorial ` for example usage. -.. autoclass:: diskcache.DjangoCache +.. autoclass:: diskcache.Cache :members: :special-members: + :exclude-members: __weakref__ FanoutCache ----------- @@ -25,12 +23,27 @@ Read the :ref:`FanoutCache tutorial ` for example usage. :special-members: :exclude-members: __weakref__ -Cache +DjangoCache +----------- + +Read the :ref:`DjangoCache tutorial ` for example usage. + +.. autoclass:: diskcache.DjangoCache + :members: + :special-members: + +Deque ----- -Read the :ref:`Cache tutorial ` for example usage. +.. autoclass:: diskcache.Deque + :members: + :special-members: + :exclude-members: __weakref__ -.. autoclass:: diskcache.Cache +Index +----- + +.. autoclass:: diskcache.Index :members: :special-members: :exclude-members: __weakref__ @@ -84,19 +97,3 @@ Timeout ------- .. autoexception:: diskcache.Timeout - -Deque ------ - -.. autoclass:: diskcache.Deque - :members: - :special-members: - :exclude-members: __weakref__ - -Index ------ - -.. autoclass:: diskcache.Index - :members: - :special-members: - :exclude-members: __weakref__ From 379ef3cfb1e152ec2bc061cc812ccb6802ca5ed0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 24 Jun 2019 11:13:00 -0700 Subject: [PATCH 155/329] Update Quickstart with caching, persistence, and recipes sections --- README.rst | 27 +++++++++++++++++++++------ docs/tutorial.rst | 13 +++++++++++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 6299ce6..7435706 100644 --- a/README.rst +++ b/README.rst @@ -91,8 +91,7 @@ Features Quickstart ---------- -Installing DiskCache is simple with -`pip `_:: +Installing `DiskCache`_ is simple with `pip `_:: $ pip install diskcache @@ -102,27 +101,43 @@ function:: >>> import diskcache >>> help(diskcache) -// caching +The core of `DiskCache`_ is three data types intended for caching. `Cache`_ +objects manage a SQLite database and filesystem directory to store key and +value pairs. `FanoutCache`_ provides a sharding layer to utilize multiple +caches and `DjangoCache`_ integrates that with `Django`_:: >>> from diskcache import Cache, FanoutCache, DjangoCache >>> help(Cache) >>> help(FanoutCache) >>> help(DjangoCache) -// persistence +Built atop the caching data types, are `Deque`_ and `Index`_ which work as a +cross-process, persistent replacement for Python's ``collections.deque`` and +``dict``. These implement the sequence and mapping container base classes:: >>> from diskcache import Deque, Index >>> help(Deque) >>> help(Index) -// recipes +Finally, a number of `recipes`_ for cross-process synchronization are provided +using an underlying cache. Features like memoization with cache stampede +prevention, cross-process locking, and cross-process throttling are available:: >>> from diskcache import memoize_stampede, Lock, throttle >>> help(memoize_stampede) >>> help(Lock) >>> help(throttle) -// tutorial and api are required reading +Python's docstrings are a quick way to get started but not intended as a +replacement for the `DiskCache Tutorial`_ and `DiskCache API Reference`_. + +.. _`Cache`: http://www.grantjenks.com/docs/diskcache/tutorial.html#cache +.. _`FanoutCache`: http://www.grantjenks.com/docs/diskcache/tutorial.html#fanoutcache +.. _`DjangoCache`: http://www.grantjenks.com/docs/diskcache/tutorial.html#djangocache +.. _`Django`: https://www.djangoproject.com/ +.. _`Deque`: http://www.grantjenks.com/docs/diskcache/tutorial.html#deque +.. _`Index`: http://www.grantjenks.com/docs/diskcache/tutorial.html#index +.. _`recipes`: http://www.grantjenks.com/docs/diskcache/tutorial.html#recipes User Guide ---------- diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 1d70e55..eef8e31 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -527,6 +527,15 @@ cross-thread and cross-process communication. :class:`Index ` objects are also useful in scenarios where contents should remain persistent or limitations prohibit holding all items in memory at the same time. +.. _tutorial-recipes: + +Recipes +------- + +.. todo:: + + Synchronization recipes. + .. _tutorial-settings: Settings @@ -723,8 +732,8 @@ cache statistics are being recorded). .. _`Python Anywhere`: https://www.pythonanywhere.com/ .. _`Parallels`: https://www.parallels.com/ -Implementation Notes --------------------- +Implementation +-------------- :doc:`DiskCache ` is mostly built on SQLite and the filesystem. Some techniques used to improve performance: From d4a0afd23e26bded1ce557e89069b67a572557c1 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 09:59:09 -0700 Subject: [PATCH 156/329] Fix typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7435706..7c953f6 100644 --- a/README.rst +++ b/README.rst @@ -112,7 +112,7 @@ caches and `DjangoCache`_ integrates that with `Django`_:: >>> help(DjangoCache) Built atop the caching data types, are `Deque`_ and `Index`_ which work as a -cross-process, persistent replacement for Python's ``collections.deque`` and +cross-process, persistent replacements for Python's ``collections.deque`` and ``dict``. These implement the sequence and mapping container base classes:: >>> from diskcache import Deque, Index From 1266d7ebe957d531e77b509b46400e9b5c762931 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 09:59:43 -0700 Subject: [PATCH 157/329] Add note about versioning --- docs/tutorial.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index eef8e31..ce33cd9 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -18,14 +18,14 @@ Pip & PyPI Installing :doc:`DiskCache ` is simple with `pip `_:: - $ pip install diskcache - -or, with `easy_install `_:: - - $ easy_install diskcache - -But `prefer pip `_ if at all -possible. + $ pip install --upgrade diskcache + +The versioning scheme uses `major.minor.micro` with `micro` intended for bug +fixes, `minor` intended for small features or improvements, and `major` +intended for significant new features and breaking changes. While it is +intended that only `major` version changes are backwards incompatible, it is +not always guaranteed. When running in production, it is recommended to pin at +least the `major` version. Get the Code ............ From 28409c65d71f03015e2bd92777860a5e887f1755 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 09:59:59 -0700 Subject: [PATCH 158/329] Refactor link to issues --- docs/tutorial.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index ce33cd9..5475246 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -51,12 +51,12 @@ or install it into your site-packages easily:: $ python setup.py install :doc:`DiskCache ` is looking for a Debian package maintainer. If you can -help, please open an issue in the `DiskCache Issue Tracker -`_. +help, please open an issue in the `DiskCache Issue Tracker`_. :doc:`DiskCache ` is looking for a CentOS/RPM package maintainer. If -you can help, please open an issue in the `DiskCache Issue Tracker -`_. +you can help, please open an issue in the `DiskCache Issue Tracker`_. + +.. _`DiskCache Issue Tracker`: https://github.com/grantjenks/python-diskcache/issues/ .. _tutorial-cache: From e572d198ee3ca72093224a3d4c894ecbca7a24dd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:00:34 -0700 Subject: [PATCH 159/329] Improve Cache tutorial --- docs/tutorial.rst | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 5475246..f18e888 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -65,22 +65,22 @@ Cache The core of :doc:`DiskCache ` is :class:`diskcache.Cache` which represents a disk and file backed cache. As a Cache, it supports a familiar -Python Mapping interface with additional cache and performance parameters. +Python mapping interface with additional cache and performance parameters. >>> from diskcache import Cache >>> cache = Cache() -Initialization requires a directory path reference. If the directory path does -not exist, it will be created. Additional keyword parameters are discussed -below. Cache objects are thread-safe and may be shared between threads. Two -Cache objects may also reference the same directory from separate threads or -processes. In this way, they are also process-safe and support cross-process -communication. +Initialization expects a directory path reference. If the directory path does +not exist, it will be created. When not specified, a temporary directory is +automatically created. Additional keyword parameters are discussed below. Cache +objects are thread-safe and may be shared between threads. Two Cache objects +may also reference the same directory from separate threads or processes. In +this way, they are also process-safe and support cross-process communication. Cache objects open and maintain one or more file handles. But unlike files, all Cache operations are atomic and Cache objects support process-forking and may be serialized using Pickle. Each thread that accesses a cache should also call -:meth:`close ` on the cache. Cache objects can be used +:meth:`close <.Cache.close>` on the cache. Cache objects can be used in a `with` statement to safeguard calling :meth:`close `. @@ -89,8 +89,8 @@ in a `with` statement to safeguard calling :meth:`close ... pass Closed Cache objects will automatically re-open when accessed. But opening -Cache objects is relatively slow, and since all operations are atomic, you can -safely leave Cache objects open. +Cache objects is relatively slow, and since all operations are atomic, may be +safely left open. >>> cache.set('key', 'value') True @@ -167,8 +167,7 @@ decrementing a missing key will raise a :exc:`KeyError`. KeyError: 'carol' Increment and decrement operations are atomic and assume the value may be -stored in a SQLite column. Most builds that target machines with 64-bit pointer -widths will support 64-bit signed integers. +stored in a SQLite integer column. SQLite supports 64-bit signed integers. Like :meth:`delete ` and :meth:`get `, the method :meth:`pop ` can be @@ -194,7 +193,7 @@ The :meth:`pop ` operation is atomic and using :meth:`incr statistics in long-running systems. Unlike :meth:`get ` the `read` argument is not supported. -Another four methods remove items from the cache. +Another four methods remove items from the cache:: >>> cache.clear() 3 @@ -318,6 +317,11 @@ consistency. It can also fix inconsistencies and reclaim unused space. The return value is a list of warnings. >>> warnings = cache.check() + +Caches do not automatically remove the underlying directory where keys and +values are stored. The cache is intended to be persistent and so must be +deleted manually. + >>> cache.close() >>> import shutil >>> try: @@ -325,6 +329,8 @@ return value is a list of warnings. ... except OSError: # Windows wonkiness ... pass +To permanently delete the cache, recursively remove the cache's directory. + .. _tutorial-fanoutcache: FanoutCache From 47fc277de355024bafd54511f21c6a19e331dee3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:00:46 -0700 Subject: [PATCH 160/329] Improve Timeout and retry tutorial --- docs/tutorial.rst | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index f18e888..a9744fb 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -348,17 +348,19 @@ transactions. Transactions are used for every operation that writes to the database. When the timeout expires, a :exc:`diskcache.Timeout` error is raised internally. This `timeout` parameter is also present on :class:`diskcache.Cache`. When a :exc:`Timeout ` error -occurs in :class:`Cache ` methods, the exception is raised to -the caller. In contrast, :class:`FanoutCache ` catches -timeout errors and aborts the operation. As a result, :meth:`set +occurs in :class:`Cache ` methods, the exception may be raised +to the caller. In contrast, :class:`FanoutCache ` +catches all timeout errors and aborts the operation. As a result, :meth:`set ` and :meth:`delete ` -methods may silently fail. Most methods that handle :exc:`Timeout -` exceptions also include a `retry` keyword parameter -(default ``False``) to automatically repeat attempts that timeout. The Mapping -interface operators: :meth:`cache[key] `, -:meth:`cache[key] = value `, and :meth:`del -cache[key] ` automatically retry operations -when :exc:`Timeout ` errors occur. :class:`FanoutCache +methods may silently fail. + +Most methods that handle :exc:`Timeout ` exceptions also +include a `retry` keyword parameter (default ``False``) to automatically repeat +attempts that timeout. The mapping interface operators: :meth:`cache[key] +`, :meth:`cache[key] = value +`, and :meth:`del cache[key] +` automatically retry operations when +:exc:`Timeout ` errors occur. :class:`FanoutCache ` will never raise a :exc:`Timeout ` exception. The default `timeout` is 0.010 (10 milliseconds). From ef087b93e98f5c9692df04db3a9f44e443ae24b6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:01:09 -0700 Subject: [PATCH 161/329] Improve memoize docs --- docs/tutorial.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a9744fb..c51430e 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -372,9 +372,9 @@ a one second timeout. Operations will attempt to abort if they take longer than one second. The remaining API of :class:`FanoutCache ` matches :class:`Cache ` as described above. -:class:`FanoutCache ` adds an additional feature: -:meth:`memoizing ` cache decorator. The -decorator wraps a callable and caches arguments and return values. +Caches have an additional feature: :meth:`memoizing +` decorator. The decorator wraps a callable and +caches arguments and return values. >>> from diskcache import FanoutCache >>> cache = FanoutCache() @@ -386,14 +386,14 @@ decorator wraps a callable and caches arguments and return values. ... return 1 ... else: ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> print(sum(fibonacci(number=value) for value in range(100))) + >>> print(sum(fibonacci(value) for value in range(100))) 573147844013817084100 The arguments to memoize are like those for `functools.lru_cache `_ and -:meth:`FanoutCache.set `. Remember to call -:meth:`memoize ` when decorating a callable. If -you forget, then a TypeError will occur. +:meth:`Cache.set <.Cache.set>`. Remember to call :meth:`memoize +<.FanoutCache.memoize>` when decorating a callable. If you forget, then a +TypeError will occur:: >>> @cache.memoize ... def test(): @@ -403,7 +403,7 @@ you forget, then a TypeError will occur. TypeError: name cannot be callable Observe the lack of parenthenses after :meth:`memoize -` above. +` above. .. _`Sharding`: https://en.wikipedia.org/wiki/Shard_(database_architecture) From 40612613ff92beec9613fcd4e85dfdcf8f8b3f2f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:01:30 -0700 Subject: [PATCH 162/329] Improve Deque and Index tutorial --- docs/tutorial.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index c51430e..63c3cb3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -479,7 +479,7 @@ Deque `_-compatible double-ended queue. Deques are a generalization of stacks and queues with fast access and editing at both front and back sides. :class:`Deque -` objects inherit the benefits of the :class:`Cache +` objects inherit the benefits of :class:`Cache ` objects but never evict items. >>> from diskcache import Deque @@ -512,7 +512,7 @@ Index `_ and `ordered dictionary `_ -interface. :class:`Index ` objects inherit the benefits of +interface. :class:`Index ` objects inherit all the benefits of :class:`Cache ` objects but never evict items. >>> from diskcache import Index @@ -533,7 +533,9 @@ interface. :class:`Index ` objects inherit the benefits of :class:`Index ` objects provide an efficient and safe means of cross-thread and cross-process communication. :class:`Index ` objects are also useful in scenarios where contents should remain persistent or -limitations prohibit holding all items in memory at the same time. +limitations prohibit holding all items in memory at the same time. The index +uses a fixed amout of memory regardless of the size or number of items stored +inside it. .. _tutorial-recipes: From 8e44b090639563fe05638bc8963953c8febc332d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:01:39 -0700 Subject: [PATCH 163/329] Add transactions section to tutorial --- docs/tutorial.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 63c3cb3..3cac7b3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -546,6 +546,23 @@ Recipes Synchronization recipes. + Update docs for recipes. + +.. _tutorial-transactions: + +Transactions +------------ + +.. todo:: + + Demonstrate use of transactions on Cache, Index, and Deque objects. + + Example, consistency: Averager + + Example, performance: get_many, set_many, delete_many + + Update docs for Cache.transact, Deque.transact, Index.transact + .. _tutorial-settings: Settings From cdc59c1b06ee0b34fd69b0b57e642bbe42b26643 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:03:59 -0700 Subject: [PATCH 164/329] Add note about FanoutCache and DjangoCache missing transactions --- docs/tutorial.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 3cac7b3..0640f07 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -557,6 +557,9 @@ Transactions Demonstrate use of transactions on Cache, Index, and Deque objects. + Missing from FanoutCache and DjangoCache due to sharding. Request Cache, + Index, or Deque object. + Example, consistency: Averager Example, performance: get_many, set_many, delete_many From 71d1b47c00602e50c3be46b5534257debdbb6c80 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:17:49 -0700 Subject: [PATCH 165/329] Document the "none" eviction policy in tutorial --- docs/api.rst | 2 ++ docs/tutorial.rst | 31 +++++++++++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 7a6d91e..1c2ed63 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -48,6 +48,8 @@ Index :special-members: :exclude-members: __weakref__ +.. _constants: + Constants --------- diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 0640f07..509d63c 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -193,6 +193,8 @@ The :meth:`pop ` operation is atomic and using :meth:`incr statistics in long-running systems. Unlike :meth:`get ` the `read` argument is not supported. +.. _tutorial-culling: + Another four methods remove items from the cache:: >>> cache.clear() @@ -645,22 +647,27 @@ accessible at :data:`diskcache.DEFAULT_SETTINGS`. Eviction Policies ----------------- -:doc:`DiskCache ` supports three eviction policies each with different +:doc:`DiskCache ` supports four eviction policies each with different tradeoffs for accessing and storing items. -* `Least Recently Stored` is the default. Every cache item records the time it - was stored in the cache. This policy adds an index to that field. On access, - no update is required. Keys are evicted starting with the oldest stored - keys. As :doc:`DiskCache ` was intended for large caches (gigabytes) - this policy usually works well enough in practice. -* `Least Recently Used` is the most commonly used policy. An index is added to - the access time field stored in the cache database. On every access, the +* ``"least-recently-stored"`` is the default. Every cache item records the time + it was stored in the cache. This policy adds an index to that field. On + access, no update is required. Keys are evicted starting with the oldest + stored keys. As :doc:`DiskCache ` was intended for large caches + (gigabytes) this policy usually works well enough in practice. +* ``"least-recently-used"`` is the most commonly used policy. An index is added + to the access time field stored in the cache database. On every access, the field is updated. This makes every access into a read and write which slows accesses. -* `Least Frequently Used` works well in some cases. An index is added to the - access count field stored in the cache database. On every access, the field - is incremented. Every access therefore requires writing the database which - slows accesses. +* ``"least-frequently-used"`` works well in some cases. An index is added to + the access count field stored in the cache database. On every access, the + field is incremented. Every access therefore requires writing the database + which slows accesses. +* ``"none"`` disables cache evictions. Caches will grow in size without + bound. Cache items will still be lazily removed if they expire. The + persistent data types, :class:`.Deque` and :class:`.Index`, use the + ``"none"`` eviction policy. For :ref:`lazy culling ` use + the :ref:`cull_limit ` setting instead. All clients accessing the cache are expected to use the same eviction policy. The policy can be set during initialization using a keyword argument. From 07f6e5ff25f22a4485c5390d6e66f8a00bb1edb0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:25:56 -0700 Subject: [PATCH 166/329] Add note about fanoutcache and shard size limit --- docs/tutorial.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 509d63c..1e43a20 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -374,6 +374,13 @@ a one second timeout. Operations will attempt to abort if they take longer than one second. The remaining API of :class:`FanoutCache ` matches :class:`Cache ` as described above. +The :class:`.FanoutCache` :ref:`size_limit ` is used as the total +size of the cache. The size limit of individual cache shards is the total size +divided by the number of shards. In the example above, the default total size +is one gigabyte and there are four shards so each cache shard has a size limit +of 256 megabytes. Items that are larger than the size limit are immediately +culled. + Caches have an additional feature: :meth:`memoizing ` decorator. The decorator wraps a callable and caches arguments and return values. From df66e3eae295133c0c785194a2045ecf22d9ec79 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:34:22 -0700 Subject: [PATCH 167/329] Remove delay fuzzer --- docs/case-study-delay-fuzzer.rst | 138 ------------------------------- 1 file changed, 138 deletions(-) delete mode 100644 docs/case-study-delay-fuzzer.rst diff --git a/docs/case-study-delay-fuzzer.rst b/docs/case-study-delay-fuzzer.rst deleted file mode 100644 index e71bdae..0000000 --- a/docs/case-study-delay-fuzzer.rst +++ /dev/null @@ -1,138 +0,0 @@ -Case Study: Delay Fuzzer -======================== - -Raymond keynote: -https://dl.dropboxusercontent.com/u/3967849/pybay2017_keynote/_build/html/index.html - -Fuzzing technique: -https://dl.dropboxusercontent.com/u/3967849/pybay2017_keynote/_build/html/threading.html#fuzzing - -Code below is simple on purpose. Not something to use in production. Ok for -testing. - -// discuss sys.settrace - - >>> def delayfuzzer(function): - ... """Insert random delays into function. - ... - ... WARNING: Not to be used in production scenarios. - ... The use of `sys.settrace` may affect other Python - ... tools like `pdb` and `coverage`. - ... - ... Decorator to insert random delays into a function to - ... encourage race conditions in multi-threaded code. - ... - ... """ - ... from functools import wraps - ... from sys import settrace - ... - ... try: - ... code = function.__code__ - ... except AttributeError: # Python 2 compatibility. - ... code = function.co_code - ... - ... def tracer(frame, event, arg): - ... "Activate sleeper in calls to function." - ... if event == 'call' and frame.f_code is code: - ... return sleeper - ... - ... @wraps(function) - ... def wrapper(*args, **kwargs): - ... """Set tracer before calling function. - ... - ... Tracing is thread-local so set the tracer before - ... every function call. - ... - ... """ - ... settrace(tracer) - ... return function(*args, **kwargs) - ... - ... return wrapper - -Sleeper function that prints location: - - >>> from time import sleep - >>> from random import expovariate - >>> def sleeper(frame, event, arg): - ... "Sleep for random period." - ... lineno = frame.f_lineno - ... print('Tracing line %s in diskcache/core.py' % lineno) - ... sleep(expovariate(100)) - -Check that it's working: - - >>> import diskcache - >>> diskcache.Cache.incr = delayfuzzer(diskcache.Cache.incr) - >>> cache = diskcache.FanoutCache('tmp') - >>> cache.incr(0) - Tracing line 797 in diskcache/core.py - Tracing line 798 in diskcache/core.py - Tracing line 800 in diskcache/core.py - Tracing line 804 in diskcache/core.py - Tracing line 805 in diskcache/core.py - Tracing line 807 in diskcache/core.py - Tracing line 808 in diskcache/core.py - Tracing line 811 in diskcache/core.py - Tracing line 812 in diskcache/core.py - Tracing line 813 in diskcache/core.py - Tracing line 814 in diskcache/core.py - Tracing line 815 in diskcache/core.py - Tracing line 815 in diskcache/core.py - 1 - >>> cache.clear() - 1 - -Simple sleeper function: - - >>> def sleeper(frame, event, arg): - ... "Sleep for random period." - ... sleep(expovariate(100)) - -Increment all numbers in a range: - - >>> def task(cache): - ... for num in range(100): - ... cache.incr(num, retry=True) - -Process worker to start many tasks in separate threads. - - >>> import threading - >>> def worker(): - ... cache = diskcache.FanoutCache('tmp') - ... threads = [] - ... - ... for num in range(8): - ... thread = threading.Thread(target=task, args=(cache,)) - ... threads.append(thread) - ... - ... for thread in threads: - ... thread.start() - ... - ... for thread in threads: - ... thread.join() - -Start many worker processes: - - >>> import multiprocessing - >>> def main(): - ... processes = [] - ... - ... for _ in range(8): - ... process = multiprocessing.Process(target=worker) - ... processes.append(process) - ... - ... for process in processes: - ... process.start() - ... - ... for process in processes: - ... process.join() - -Ok, here goes: - - >>> main() - >>> sorted(cache) == list(range(100)) - True - >>> all(cache[key] == 64 for key in cache) - True - -Yaay! It worked. From 935cc3933bed14c57ec8785ee4f87aa877439491 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Jun 2019 10:34:36 -0700 Subject: [PATCH 168/329] Add link to landing page caching --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 7c953f6..d0b74d0 100644 --- a/README.rst +++ b/README.rst @@ -149,6 +149,7 @@ tutorial, benchmarks, API, and development. * `DiskCache Cache Benchmarks`_ * `DiskCache DjangoCache Benchmarks`_ * `Case Study: Web Crawler`_ +* `Case Study: Landing Page Caching`_ * `Talk: All Things Cached - SF Python 2017 Meetup`_ * `DiskCache API Reference`_ * `DiskCache Development`_ @@ -158,6 +159,7 @@ tutorial, benchmarks, API, and development. .. _`DiskCache DjangoCache Benchmarks`: http://www.grantjenks.com/docs/diskcache/djangocache-benchmarks.html .. _`Talk: All Things Cached - SF Python 2017 Meetup`: http://www.grantjenks.com/docs/diskcache/sf-python-2017-meetup-talk.html .. _`Case Study: Web Crawler`: http://www.grantjenks.com/docs/diskcache/case-study-web-crawler.html +.. _`Case Study: Landing Page Caching`: http://www.grantjenks.com/docs/diskcache/case-study-landing-page-caching.html .. _`DiskCache API Reference`: http://www.grantjenks.com/docs/diskcache/api.html .. _`DiskCache Development`: http://www.grantjenks.com/docs/diskcache/development.html From 8d950e5e4762c576ca381ed554a378f24670a287 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:42:47 -0700 Subject: [PATCH 169/329] Use reference in with-statement --- docs/tutorial.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 1e43a20..f726642 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -86,14 +86,13 @@ in a `with` statement to safeguard calling :meth:`close >>> cache.close() >>> with Cache(cache.directory) as reference: - ... pass + ... reference.set('key', 'value') + True Closed Cache objects will automatically re-open when accessed. But opening Cache objects is relatively slow, and since all operations are atomic, may be safely left open. - >>> cache.set('key', 'value') - True >>> cache.close() >>> cache.get('key') # Automatically opens, but slower. 'value' From 6ed398b4d615c46f47b63c1a0a889eb5ff14c66e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:43:09 -0700 Subject: [PATCH 170/329] Add quick note on touch() api --- docs/tutorial.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index f726642..1bc6b6e 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -131,6 +131,14 @@ The return value is a tuple containing the value, expire time (seconds from epoch), and tag. Because we passed ``read=True`` the value is returned as a file-like object. +Use :meth:`touch <.Cache.touch>` to update the expiration time of an item in +the cache. + + >>> cache.touch('key', expire=None) + True + >>> cache.touch('does-not-exist', expire=1) + False + Like :meth:`set `, the method :meth:`add ` can be used to insert an item in the cache. The item is inserted only if the key is not already present. From 1506b722dd6ed6107f2b0640eeb24df22d853bae Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:43:24 -0700 Subject: [PATCH 171/329] Improve wording --- docs/tutorial.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 1bc6b6e..8cc3035 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -280,8 +280,8 @@ remove items until the cache volume is less than the size limit. True Some users may defer all culling to a cron-like process by setting the -:ref:`cull_limit ` to zero and calling :meth:`cull -` to manually remove items. Like :meth:`evict +:ref:`cull_limit ` to zero and manually calling :meth:`cull +` to remove items. Like :meth:`evict ` and :meth:`expire `, calls to :meth:`cull ` will work regardless of the :ref:`cull_limit `. From 2e344cdd1c754ae0ac3c7210fc89a364dcef88ca Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:44:12 -0700 Subject: [PATCH 172/329] Add section on insertion order and sort order --- docs/tutorial.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8cc3035..c79401b 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -294,6 +294,26 @@ Some users may defer all culling to a cron-like process by setting the Each of these methods is designed to work concurrent to others. None of them block readers or writers in other threads or processes. +Caches may be iterated by either insertion order or sorted order. The default +ordering uses insertion order. To iterate by sorted order, use :meth:`iterkeys +<.Cache.iterkeys>`. The sort order is determined by the database which makes it +valid only for `str`, `bytes`, `int`, and `float` data types. Other types of +keys will be serialized which is likely to have a meaningless sorted order. + + >>> for key in 'cab': + ... cache[key] = None + >>> list(cache) + ['c', 'a', 'b'] + >>> list(cache.iterkeys()) + ['a', 'b', 'c'] + >>> cache.peekitem() + ('b', None) + >>> cache.peekitem(last=False) + ('c', None) + +If only the first or last item in insertion order is desired then +:meth:`peekitem <.Cache.peekitem>` is more efficient than using iteration. + Lastly, three methods support metadata about the cache. The first is :meth:`volume ` which returns the estimated total size in bytes of the cache directory on disk. From 55ce695ce0b80363ddce5e09bc5ce1f9c6c4658a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:44:31 -0700 Subject: [PATCH 173/329] Add section on push, pull, and peek --- docs/tutorial.rst | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index c79401b..8fe4c46 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -314,6 +314,36 @@ keys will be serialized which is likely to have a meaningless sorted order. If only the first or last item in insertion order is desired then :meth:`peekitem <.Cache.peekitem>` is more efficient than using iteration. +Three additional methods use the sorted ordering of keys to maintain a +queue-like data structure within the cache. The :meth:`push <.Cache.push>`, +:meth:`pull <.Cache.pull>`, and :meth:`peek <.Cache.peek>` methods +automatically assign the key within the cache. + + >>> key = cache.push('first') + >>> print(key) + 500000000000000 + >>> cache[key] + 'first' + >>> _ = cache.push('second') + >>> _ = cache.push('zeroth', side='front') + >>> _, value = cache.peek() + >>> value + 'zeroth' + >>> key, value = cache.pull() + >>> print(key) + 499999999999999 + >>> value + 'zeroth' + +The `side` parameter supports access to either the ``'front'`` or ``'back'`` of +the cache. In addition, the `prefix` parameter can be used to maintain multiple +queue-like data structures within a single cache. When prefix is ``None``, +integer keys are used. Otherwise, string keys are used in the format +“prefix-integer”. Integer starts at 500 trillion. Like :meth:`set <.Cache.set>` +and :meth:`get <.Cache.get>`, methods :meth:`push <.Cache.push>`, :meth:`pull +<.Cache.pull>`, and :meth:`peek <.Cache.peek>` support cache metadata like the +expiration time and tag. + Lastly, three methods support metadata about the cache. The first is :meth:`volume ` which returns the estimated total size in bytes of the cache directory on disk. From 616f848dcb61f6820714606a377fdffccec5cdc5 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 09:48:27 -0700 Subject: [PATCH 174/329] Add reference to push, pull, and peek in deque --- docs/tutorial.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8fe4c46..ce9155f 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -545,8 +545,9 @@ Deque `_-compatible double-ended queue. Deques are a generalization of stacks and queues with fast access and editing at both front and back sides. :class:`Deque -` objects inherit the benefits of :class:`Cache -` objects but never evict items. +` objects use the :meth:`push <.Cache.push>`, :meth:`pull +<.Cache.pull>`, and :meth:`peek <.Cache.peek>` methods of :class:`Cache +<.Cache>` objects but never evict or expire items. >>> from diskcache import Deque >>> deque = Deque(range(5, 10)) @@ -579,7 +580,7 @@ Index and `ordered dictionary `_ interface. :class:`Index ` objects inherit all the benefits of -:class:`Cache ` objects but never evict items. +:class:`Cache ` objects but never evict or expire items. >>> from diskcache import Index >>> index = Index([('a', 1), ('b', 2), ('c', 3)]) From 4b7e395a0cdf99033c4cbd5dd36b30c9a66a8166 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 11:06:46 -0700 Subject: [PATCH 175/329] Add recipes in tutorial and api docs --- docs/api.rst | 21 +++++++++++++++++++++ docs/tutorial.rst | 22 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index 1c2ed63..99686bb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -48,6 +48,27 @@ Index :special-members: :exclude-members: __weakref__ +Recipes +------- + +.. autoclass:: diskcache.Averager + :members: + +.. autoclass:: diskcache.Lock + :members: + +.. autoclass:: diskcache.RLock + :members: + +.. autoclass:: diskcache.BoundedSemaphore + :members: + +.. autodecorator:: diskcache.throttle + +.. autodecorator:: diskcache.barrier + +.. autodecorator:: diskcache.memoize_stampede + .. _constants: Constants diff --git a/docs/tutorial.rst b/docs/tutorial.rst index ce9155f..c17b6a6 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -630,8 +630,28 @@ Transactions Example, consistency: Averager Example, performance: get_many, set_many, delete_many +.. _tutorial-recipes: + +Recipes +------- - Update docs for Cache.transact, Deque.transact, Index.transact +:doc:`DiskCache ` includes a few synchronization recipes for +cross-thread and cross-process communication: + +* :class:`.Averager` -- maintains a running average like that shown above. +* :class:`.Lock`, :class:`.RLock`, and :class:`.BoundedSemaphore` -- recipes + for synchronization around critical sections like those found in Python's + `threading`_ and `multiprocessing`_ modules. +* :func:`throttle <.throttle>` -- function decorator to rate-limit calls to a + function. +* :func:`barrier <.barrier>` -- function decorator to synchronize calls to a + function. +* :func:`memoize_stampede <.memoize_stampede>` -- memoizing function decorator + with cache stampede protection. Read :doc:`case-study-landing-page-caching` + for a comparison of memoization strategies. + +.. _threading: https://docs.python.org/3/library/threading.html +.. _multiprocessing: https://docs.python.org/3/library/multiprocessing.html .. _tutorial-settings: From e4cd4dc0daf7ff9e56fb3d564fcc060147dc48ad Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 11:07:12 -0700 Subject: [PATCH 176/329] Add note about memory usage and deques --- docs/tutorial.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index c17b6a6..017a8df 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -569,7 +569,9 @@ access and editing at both front and back sides. :class:`Deque :class:`Deque ` objects provide an efficient and safe means of cross-thread and cross-process communication. :class:`Deque ` objects are also useful in scenarios where contents should remain persistent or -limitations prohibit holding all items in memory at the same time. +limitations prohibit holding all items in memory at the same time. The deque +uses a fixed amout of memory regardless of the size or number of items stored +inside it. Index ----- From 126b033c5715fd058a258fc3c104b7c8d0858fbb Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 11:07:44 -0700 Subject: [PATCH 177/329] Add tutorial section on Transactions --- docs/tutorial.rst | 58 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 017a8df..e028e54 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -606,32 +606,60 @@ limitations prohibit holding all items in memory at the same time. The index uses a fixed amout of memory regardless of the size or number of items stored inside it. -.. _tutorial-recipes: +.. _tutorial-transactions: -Recipes -------- +Transactions +------------ -.. todo:: +Transactions are implemented by the :class:`.Cache`, :class:`.Deque`, and +:class:`.Index` data types and support consistency and improved +performance. Use transactions to guarantee a group of operations occur +atomically. For example, to calculate a running average, the total and count +could be incremented together:: + + >>> with cache.transact(): + ... total = cache.incr('total', 123.45) + ... count = cache.incr('count') + >>> total + 123.45 + >>> count + 1 - Synchronization recipes. +And to calculate the average, the values could be retrieved together: - Update docs for recipes. + >>> with cache.transact(): + ... total = cache.get('total') + ... count = cache.get('count') + >>> average = None if count == 0 else total / count + >>> average + 123.45 -.. _tutorial-transactions: +Keep transactions as short as possible because within a transaction, no other +writes may occur to the cache. Every write operation uses a transaction and +transactions may be nested to improve performance. For example, a possible +implementation to set many items within the cache:: -Transactions ------------- + >>> def set_many(cache, mapping): + ... with cache.transact(): + ... for key, value in mapping.items(): + ... cache[key] = value -.. todo:: +By grouping all operations in a single transaction, performance may improve two +to five times. But be careful, a large mapping will block other concurrent +writers. - Demonstrate use of transactions on Cache, Index, and Deque objects. +Transactions are not implemented by :class:`.FanoutCache` and +:class:`.DjangoCache` due to key sharding. Instead, a cache shard with +transaction support may be requested. - Missing from FanoutCache and DjangoCache due to sharding. Request Cache, - Index, or Deque object. + >>> fanout_cache = FanoutCache() + >>> tutorial_cache = fanout_cache.cache('tutorial') + >>> username_queue = fanout_cache.deque('usernames') + >>> url_to_response = fanout_cache.index('responses') - Example, consistency: Averager +The cache shard exists in a subdirectory of the fanout-cache with the given +name. - Example, performance: get_many, set_many, delete_many .. _tutorial-recipes: Recipes From 5eff80e019de2158d126997655481030223a744b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 11:08:05 -0700 Subject: [PATCH 178/329] Improve formatting for autodoc --- diskcache/recipes.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 48b8241..08b873b 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -33,15 +33,17 @@ class Averager(object): total and count. The average can then be calculated at any time. >>> import diskcache - >>> cache = diskcache.Cache() + >>> cache = diskcache.FanoutCache() >>> ave = Averager(cache, 'latency') >>> ave.add(0.080) >>> ave.add(0.120) >>> ave.get() 0.1 >>> ave.add(0.160) - >>> ave.get() + >>> ave.pop() 0.12 + >>> print(ave.get()) + None """ def __init__(self, cache, key, expire=None, tag=None): @@ -61,14 +63,14 @@ def add(self, value): ) def get(self): - "Get current average." + "Get current average or return `None` if count equals zero." total, count = self._cache.get(self._key, default=(0.0, 0), retry=True) - return 0.0 if count == 0 else total / count + return None if count == 0 else total / count def pop(self): - "Return current average and reset average to 0.0." + "Return current average and delete key." total, count = self._cache.pop(self._key, default=(0.0, 0), retry=True) - return 0.0 if count == 0 else total / count + return None if count == 0 else total / count class Lock(object): @@ -178,7 +180,7 @@ class BoundedSemaphore(object): >>> import diskcache >>> cache = diskcache.Cache() - >>> semaphore = BoundedSemaphore(cache, 'max-connections', value=2) + >>> semaphore = BoundedSemaphore(cache, 'max-cons', value=2) >>> semaphore.acquire() >>> semaphore.acquire() >>> semaphore.release() @@ -242,7 +244,7 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, >>> start = time.time() >>> while (time.time() - start) <= 2: ... increment() - >>> count in (6, 7) # 6 or 7 calls depending on processor load + >>> count in (6, 7) # 6 or 7 calls depending on CPU load True """ @@ -329,7 +331,7 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1): probabilistically before expiration in a background thread of execution. Early probabilistic recomputation is based on research by Vattani, A.; Chierichetti, F.; Lowenstein, K. (2015), Optimal Probabilistic - Cache Stampede Prevention, VLDB, pp. 886?897, ISSN 2150-8097 + Cache Stampede Prevention, VLDB, pp. 886-897, ISSN 2150-8097 If name is set to None (default), the callable name will be determined automatically. @@ -338,27 +340,27 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1): cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. - The original underlying function is accessible through the __wrapped__ + The original underlying function is accessible through the `__wrapped__` attribute. This is useful for introspection, for bypassing the cache, or for rewrapping the function with a different cache. >>> from diskcache import Cache >>> cache = Cache() >>> @memoize_stampede(cache, expire=1) - ... def fibonacci(number): + ... def fib(number): ... if number == 0: ... return 0 ... elif number == 1: ... return 1 ... else: - ... return fibonacci(number - 1) + fibonacci(number - 2) - >>> print(fibonacci(100)) + ... return fib(number - 1) + fib(number - 2) + >>> print(fib(100)) 354224848179261915075 An additional `__cache_key__` attribute can be used to generate the cache key used for the given arguments. - >>> key = fibonacci.__cache_key__(100) + >>> key = fib.__cache_key__(100) >>> del cache[key] Remember to call memoize when decorating a callable. If you forget, then a From dfad0aa27362354901d90457e465b8b246570c3e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 11:23:41 -0700 Subject: [PATCH 179/329] Update caveats regarding asyncio support --- docs/tutorial.rst | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index e028e54..8b20df9 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -865,24 +865,37 @@ floats, equality matches Python's definition. But large integers and all other types will be converted to bytes using pickling and the bytes representation will define equality. -:doc:`DiskCache ` uses SQLite to synchronize database access between -threads and processes and as such inherits all SQLite caveats. Most notably -SQLite is `not recommended`_ for use with Network File System (NFS) mounts. For -this reason, :doc:`DiskCache ` currently `performs poorly`_ on `Python -Anywhere`_. Users have also reported issues running inside of `Parallels`_ -shared folders. - -:doc:`DiskCache ` uses transactions when writing data to disk using -SQLite. When the disk or database is full, a :exc:`sqlite3.OperationalError` -will be raised from any method that attempts to write data. Read operations -will still succeed so long as they do not cause any write (as might occur if -cache statistics are being recorded). +SQLite is used to synchronize database access between threads and processes and +as such inherits all SQLite caveats. Most notably SQLite is `not recommended`_ +for use with Network File System (NFS) mounts. For this reason, :doc:`DiskCache +` currently `performs poorly`_ on `Python Anywhere`_. Users have also +reported issues running inside of `Parallels`_ shared folders. + +When the disk or database is full, a :exc:`sqlite3.OperationalError` will be +raised from any method that attempts to write data. Read operations will still +succeed so long as they do not cause any write (as might occur if cache +statistics are being recorded). + +Asynchronous support using Python's ``async`` and ``await`` keywords and +`asyncio`_ module is blocked by a lack of support in the underlying SQLite +module. But it is possible to run :doc:`DiskCache ` methods in a +thread-pool executor asynchronously. For example:: + + >>> import asyncio + >>> async def set_async(key, val): + ... loop = asyncio.get_running_loop() + ... future = loop.run_in_executor(None, cache.set, key, val) + ... result = await future + ... return result + >>> asyncio.run(set_async('test-key', 'test-value')) + True .. _`hash protocol`: https://docs.python.org/library/functions.html#hash .. _`not recommended`: https://www.sqlite.org/faq.html#q5 .. _`performs poorly`: https://www.pythonanywhere.com/forums/topic/1847/ .. _`Python Anywhere`: https://www.pythonanywhere.com/ .. _`Parallels`: https://www.parallels.com/ +.. _`asyncio`: https://docs.python.org/3/library/asyncio.html Implementation -------------- From 5682d1dbf3fa1a1c91aa95b917c541c386ebc754 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 12:05:32 -0700 Subject: [PATCH 180/329] Half-draft of landing page caching case study --- docs/case-study-landing-page-caching.rst | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/case-study-landing-page-caching.rst b/docs/case-study-landing-page-caching.rst index eb5dabe..97010aa 100644 --- a/docs/case-study-landing-page-caching.rst +++ b/docs/case-study-landing-page-caching.rst @@ -4,10 +4,52 @@ Case Study: Landing Page Caching :doc:`DiskCache ` version 4 added recipes for cache stampede mitigation. Let's look at how that applies to landing page caching. + >>> import time + >>> def generate_landing_page(): + ... time.sleep(0.2) # Work really hard. + ... # Return HTML response. + +Imagine a website under heavy load with a function used to generate the landing +page. There are two processes each with five threads for a total of ten +concurrent workers. Also assume that generating the landing page takes about +two hundred milliseconds. + .. image:: _static/no-caching.png +When we look at the number of concurrent workers and the latency with no +caching at all, the graph looks as above. Notice each worker constantly +regenerates the page with a consistently slow latency. + + >>> import diskcache as dc + >>> cache = dc.Cache() + >>> @cache.memoize(expire=1) + ... def generate_landing_page(): + ... time.sleep(0.2) + +With traditional caching, the result of generating the landing page can be +memoized for one second. After each second, the cached HTML expires and all ten +workers rush to regenerate the result. + .. image:: _static/traditional-caching.png +There is a huge improvement in average latency now but some requests experience +worse latency than before due to the added overhead of caching. The cache +stampede is visible now as the spikes in the concurrency graph. If generating +the landing page requires significant resources then the spikes may be +prohibitive. + +To reduce the number of concurrent workers, a barrier can be used to +synchronize generating the landing page:: + + >>> @cache.memoize(expire=0) + ... @dc.barrier(cache, dc.Lock) + ... @cache.memoize(expire=1) + ... def generate_landing_page(): + ... time.sleep(0.2) + +The double-checked locking uses two memoization decorators to optimistically +look up the cache result before locking. + .. image:: _static/synchronized-locking.png .. image:: _static/early-recomputation.png From 4b721b3bad706e049548bb17a435e26e93e28891 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 1 Jul 2019 17:42:39 -0700 Subject: [PATCH 181/329] Show asyncio without running through doctests --- docs/tutorial.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8b20df9..a4c3317 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -881,14 +881,15 @@ Asynchronous support using Python's ``async`` and ``await`` keywords and module. But it is possible to run :doc:`DiskCache ` methods in a thread-pool executor asynchronously. For example:: - >>> import asyncio - >>> async def set_async(key, val): - ... loop = asyncio.get_running_loop() - ... future = loop.run_in_executor(None, cache.set, key, val) - ... result = await future - ... return result - >>> asyncio.run(set_async('test-key', 'test-value')) - True + import asyncio + + async def set_async(key, val): + loop = asyncio.get_running_loop() + future = loop.run_in_executor(None, cache.set, key, val) + result = await future + return result + + asyncio.run(set_async('test-key', 'test-value')) .. _`hash protocol`: https://docs.python.org/library/functions.html#hash .. _`not recommended`: https://www.sqlite.org/faq.html#q5 From 2d1f43ea2be4c82a430d245de6260c3e18059ba1 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 10:13:55 -0700 Subject: [PATCH 182/329] Final draft of case study for landing page caching --- diskcache/recipes.py | 8 +-- docs/case-study-landing-page-caching.rst | 81 +++++++++++++++++++++--- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 08b873b..fb64250 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -321,10 +321,10 @@ def wrapper(*args, **kwargs): def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1): """Memoizing cache decorator with cache stampede protection. - Cache stampedes are a type of cascading failure that can occur when - parallel computing systems using memoization come under heavy load. This - behaviour is sometimes also called dog-piling, cache miss storm, cache - choking, or the thundering herd problem. + Cache stampedes are a type of system overload that can occur when parallel + computing systems using memoization come under heavy load. This behaviour + is sometimes also called dog-piling, cache miss storm, cache choking, or + the thundering herd problem. The memoization decorator implements cache stampede protection through early recomputation. Early recomputation of function results will occur diff --git a/docs/case-study-landing-page-caching.rst b/docs/case-study-landing-page-caching.rst index 97010aa..a85d6fc 100644 --- a/docs/case-study-landing-page-caching.rst +++ b/docs/case-study-landing-page-caching.rst @@ -2,7 +2,11 @@ Case Study: Landing Page Caching ================================ :doc:`DiskCache ` version 4 added recipes for cache stampede mitigation. -Let's look at how that applies to landing page caching. +Cache stampedes are a type of system overload that can occur when parallel +computing systems using memoization come under heavy load. This behaviour is +sometimes also called dog-piling, cache miss storm, cache choking, or the +thundering herd problem. Let's look at how that applies to landing page +caching. >>> import time >>> def generate_landing_page(): @@ -10,9 +14,9 @@ Let's look at how that applies to landing page caching. ... # Return HTML response. Imagine a website under heavy load with a function used to generate the landing -page. There are two processes each with five threads for a total of ten -concurrent workers. Also assume that generating the landing page takes about -two hundred milliseconds. +page. There are five processes each with two threads for a total of ten +concurrent workers. The landing page is loaded constantly and takes about two +hundred milliseconds to generate. .. image:: _static/no-caching.png @@ -26,15 +30,15 @@ regenerates the page with a consistently slow latency. ... def generate_landing_page(): ... time.sleep(0.2) -With traditional caching, the result of generating the landing page can be -memoized for one second. After each second, the cached HTML expires and all ten -workers rush to regenerate the result. +Assume the result of generating the landing page can be memoized for one +second. Memoization supports a traditional caching strategy. After each second, +the cached HTML expires and all ten workers rush to regenerate the result. .. image:: _static/traditional-caching.png There is a huge improvement in average latency now but some requests experience worse latency than before due to the added overhead of caching. The cache -stampede is visible now as the spikes in the concurrency graph. If generating +stampede is visible too as the spikes in the concurrency graph. If generating the landing page requires significant resources then the spikes may be prohibitive. @@ -48,12 +52,71 @@ synchronize generating the landing page:: ... time.sleep(0.2) The double-checked locking uses two memoization decorators to optimistically -look up the cache result before locking. +look up the cached result before locking. With `expire` set to zero, the +cache's get-operation is performed but the set-operation is skipped. Only the +inner-nested memoize decorator will update the cache. .. image:: _static/synchronized-locking.png +The number of concurrent workers is now greatly improved. Rather than having +ten workers all attempt to generate the same result, a single worker generates +the result and the other ten benefit. The maximum latency has increased however +as three layers of caching and locking wrap the function. + +Ideally, the system would anticipate the pending expiration of the cached item +and would recompute the result in a separate thread of execution. Coordinating +recomputation would be a function of the number of workers, the expiration +time, and the duration of computation. Fortunately, Vattani, et al. published +the solution in "Optimal Probabilistic Cache Stampede Prevention" in 2015. + + >>> @dc.memoize_stampede(cache, expire=1) + ... def generate_landing_page(): + ... time.sleep(0.2) + +Early probabilistic recomputation uses a random number generator to simulate a +cache miss prior to expiration. The new result is then computed in a separate +thread while the cached result is returned to the caller. When the cache item +is missing, the result is computed and cached synchronously. + .. image:: _static/early-recomputation.png +The latency is now its theoretical best. An initial warmup execution takes two +hundred milliseconds and the remaining calls all return immediately from the +cache. Behind the scenes, separate threads of execution are recomputing the +result of workers and updating the cache. The concurrency graph shows a nearly +constant stream of workers recomputing the function's result. + + >>> @dc.memoize_stampede(cache, expire=1, beta=0.5) + ... def generate_landing_page(): + ... time.sleep(0.2) + +Vattani described an additional parameter, :math:`\beta`, which could be used +to tune the eagerness of recomputation. As the number and frequency of +concurrent worker calls increases, eagerness can be lessened by decreasing the +:math:`\beta` parameter. The default value of :math:`\beta` is one, and above +it is set to half. + .. image:: _static/early-recomputation-05.png +Latency is now still its theoretical best while the worker load has decreased +significantly. The likelihood of simulated cache misses is now half what it was +before. The value was determined through experimentation. + + >>> @dc.memoize_stampede(cache, expire=1, beta=0.3) + ... def generate_landing_page(): + ... time.sleep(0.2) + +Lets see what happens when :math:`\beta` is set too low. + .. image:: _static/early-recomputation-03.png + +When set too low, the cache item expires before a new value is recomputed. The +real cache miss then causes the workers to synchronously recompute the landing +page and cache the result. With no barrier in place, eleven workers cause a +cache stampede. The eleven workers are composed of ten synchronous workers and +one in a background thread. The best way to customize :math:`\beta` is through +experimentation, otherwise the default is very reasonable. + +:doc:`DiskCache ` provides data types and recipes for memoization and +mitigation of cache stampedes. The decorators provided are composable for a +variety of scenarios. The best way to get started is with the :doc:`tutorial`. From 1149e822c4a9b8bb8eaf27874c228100ddc380b1 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 10:39:00 -0700 Subject: [PATCH 183/329] Change doctest-like snippets to code-blocks with emphasis --- docs/case-study-landing-page-caching.rst | 68 +++++++++++++++--------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/docs/case-study-landing-page-caching.rst b/docs/case-study-landing-page-caching.rst index a85d6fc..14b2de0 100644 --- a/docs/case-study-landing-page-caching.rst +++ b/docs/case-study-landing-page-caching.rst @@ -8,10 +8,13 @@ sometimes also called dog-piling, cache miss storm, cache choking, or the thundering herd problem. Let's look at how that applies to landing page caching. - >>> import time - >>> def generate_landing_page(): - ... time.sleep(0.2) # Work really hard. - ... # Return HTML response. +.. code-block:: python + + import time + + def generate_landing_page(): + time.sleep(0.2) # Work really hard. + # Return HTML response. Imagine a website under heavy load with a function used to generate the landing page. There are five processes each with two threads for a total of ten @@ -24,11 +27,16 @@ When we look at the number of concurrent workers and the latency with no caching at all, the graph looks as above. Notice each worker constantly regenerates the page with a consistently slow latency. - >>> import diskcache as dc - >>> cache = dc.Cache() - >>> @cache.memoize(expire=1) - ... def generate_landing_page(): - ... time.sleep(0.2) +.. code-block:: python + :emphasize-lines: 5 + + import diskcache as dc + + cache = dc.Cache() + + @cache.memoize(expire=1) + def generate_landing_page(): + time.sleep(0.2) Assume the result of generating the landing page can be memoized for one second. Memoization supports a traditional caching strategy. After each second, @@ -43,13 +51,16 @@ the landing page requires significant resources then the spikes may be prohibitive. To reduce the number of concurrent workers, a barrier can be used to -synchronize generating the landing page:: +synchronize generating the landing page. - >>> @cache.memoize(expire=0) - ... @dc.barrier(cache, dc.Lock) - ... @cache.memoize(expire=1) - ... def generate_landing_page(): - ... time.sleep(0.2) +.. code-block:: python + :emphasize-lines: 1,2,3 + + @cache.memoize(expire=0) + @dc.barrier(cache, dc.Lock) + @cache.memoize(expire=1) + def generate_landing_page(): + time.sleep(0.2) The double-checked locking uses two memoization decorators to optimistically look up the cached result before locking. With `expire` set to zero, the @@ -69,9 +80,12 @@ recomputation would be a function of the number of workers, the expiration time, and the duration of computation. Fortunately, Vattani, et al. published the solution in "Optimal Probabilistic Cache Stampede Prevention" in 2015. - >>> @dc.memoize_stampede(cache, expire=1) - ... def generate_landing_page(): - ... time.sleep(0.2) +.. code-block:: python + :emphasize-lines: 1 + + @dc.memoize_stampede(cache, expire=1) + def generate_landing_page(): + time.sleep(0.2) Early probabilistic recomputation uses a random number generator to simulate a cache miss prior to expiration. The new result is then computed in a separate @@ -86,9 +100,12 @@ cache. Behind the scenes, separate threads of execution are recomputing the result of workers and updating the cache. The concurrency graph shows a nearly constant stream of workers recomputing the function's result. - >>> @dc.memoize_stampede(cache, expire=1, beta=0.5) - ... def generate_landing_page(): - ... time.sleep(0.2) +.. code-block:: python + :emphasize-lines: 1 + + @dc.memoize_stampede(cache, expire=1, beta=0.5) + def generate_landing_page(): + time.sleep(0.2) Vattani described an additional parameter, :math:`\beta`, which could be used to tune the eagerness of recomputation. As the number and frequency of @@ -102,9 +119,12 @@ Latency is now still its theoretical best while the worker load has decreased significantly. The likelihood of simulated cache misses is now half what it was before. The value was determined through experimentation. - >>> @dc.memoize_stampede(cache, expire=1, beta=0.3) - ... def generate_landing_page(): - ... time.sleep(0.2) +.. code-block:: python + :emphasize-lines: 1 + + @dc.memoize_stampede(cache, expire=1, beta=0.3) + def generate_landing_page(): + time.sleep(0.2) Lets see what happens when :math:`\beta` is set too low. From c7f826a22f27e7e70ac431b37d5689e7da705e60 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 11:22:18 -0700 Subject: [PATCH 184/329] Invoke scripts with "python -m" and use "tox -e py" --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2c15404..c67140c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ sudo: false language: python -install: pip install tox -script: tox +install: python -m pip install tox +script: python -m tox -e py matrix: include: - python: 2.7 From 845ae3ed8e28c51eee3403adc2c927a72d37daa6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 11:24:35 -0700 Subject: [PATCH 185/329] Pin pytest to 4.6.* for AppVeyor support on Python 2.7 and 3.4 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 0566db9..e17bdf4 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ skip_missing_interpreters=True deps= django==1.11.* mock - pytest + pytest==4.6.* pytest-django pytest-xdist commands=python -m pytest From 2c79bb981ea812f775331f758b07ce30fb0f864c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 16:55:00 -0700 Subject: [PATCH 186/329] Bump version to 4.0.0 --- diskcache/__init__.py | 4 ++-- tox.ini | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 96a5732..46536bb 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -43,8 +43,8 @@ pass __title__ = 'diskcache' -__version__ = '3.1.1' -__build__ = 0x030101 +__version__ = '4.0.0' +__build__ = 0x040000 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2018 Grant Jenks' diff --git a/tox.ini b/tox.ini index e17bdf4..43839bf 100644 --- a/tox.ini +++ b/tox.ini @@ -33,3 +33,6 @@ deps= django==1.11.* pylint commands=pylint diskcache + +[doc8] +ignore=D000 From ab8713e271ec8e7fdef9513c31a3a38544ecb5dd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 27 Sep 2019 22:33:28 -0700 Subject: [PATCH 187/329] Add deepsource setup file --- .deepsource.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .deepsource.toml diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000..154e132 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,14 @@ +version = 1 + +test_patterns = [ + '*/tests/*' +] + +exclude_patterns = [ + +] + +[[analyzers]] +name = 'python' +enabled = true +runtime_version = '3.x.x' From ee47430ce388e4373caa2033307c8b2f321c4638 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 21:20:45 -0700 Subject: [PATCH 188/329] Add rstcheck to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 00f826c..659180d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ pytest-cov pytest-django pytest-env pytest-xdist +rstcheck sphinx tox twine From 798c3ea4052a78f02bdaa6c52dc73646cd155746 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 28 Sep 2019 11:39:23 -0700 Subject: [PATCH 189/329] Remove deepsource setup --- .deepsource.toml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .deepsource.toml diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 154e132..0000000 --- a/.deepsource.toml +++ /dev/null @@ -1,14 +0,0 @@ -version = 1 - -test_patterns = [ - '*/tests/*' -] - -exclude_patterns = [ - -] - -[[analyzers]] -name = 'python' -enabled = true -runtime_version = '3.x.x' From 8ba1d34972835a143bedda5927b4cdca338c2ebf Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 28 Sep 2019 22:03:14 -0700 Subject: [PATCH 190/329] Proselint fixes --- README.rst | 10 +++++----- docs/cache-benchmarks.rst | 6 +++--- docs/case-study-landing-page-caching.rst | 4 ++-- docs/case-study-web-crawler.rst | 2 +- docs/djangocache-benchmarks.rst | 6 +++--- docs/sf-python-2017-meetup-talk.rst | 4 ++-- docs/tutorial.rst | 14 +++++++------- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index d0b74d0..57b7a2e 100644 --- a/README.rst +++ b/README.rst @@ -234,7 +234,7 @@ Integrations? Django None None None None **Timings** -These are very rough measurements. See `DiskCache Cache Benchmarks`_ for more +These are rough measurements. See `DiskCache Cache Benchmarks`_ for more rigorous data. ================ ============= ========= ========= ============ ============ @@ -279,8 +279,8 @@ Pure-Python Databases ..................... * `ZODB`_ supports an isomorphic interface for database operations which means - there's very little impact on your code to make objects persistent and - there's no database mapper that partially hides the datbase. + there's little impact on your code to make objects persistent and there's no + database mapper that partially hides the datbase. * `CodernityDB`_ is an open source, pure-Python, multi-platform, schema-less, NoSQL database and includes an HTTP server version, and a Python client library that aims to be 100% compatible with the embedded version. @@ -332,7 +332,7 @@ SQL Databases PostgreSQL adapter for the Python programming language. * `Oracle DB`_ is a relational database management system (RDBMS) from the Oracle Corporation. Originally developed in 1977, Oracle DB is one of the - most trusted and widely-used enterprise relational database engines. + most trusted and widely used enterprise relational database engines. * `Microsoft SQL Server`_ is a relational database management system developed by Microsoft. As a database server, it stores and retrieves data as requested by other software applications. @@ -398,7 +398,7 @@ License at Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied. See the License for the +CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. .. _`DiskCache`: http://www.grantjenks.com/docs/diskcache/ diff --git a/docs/cache-benchmarks.rst b/docs/cache-benchmarks.rst index 3f0343d..5b125e8 100644 --- a/docs/cache-benchmarks.rst +++ b/docs/cache-benchmarks.rst @@ -116,7 +116,7 @@ Timings for pylibmc.Client Total 98999 2.669s ========= ========= ========= ========= ========= ========= ========= ========= -Memcached performance is low latency and very stable. +Memcached performance is low latency and stable. ========= ========= ========= ========= ========= ========= ========= ========= Timings for redis.StrictRedis @@ -144,8 +144,8 @@ Get .. image:: _static/core-p8-get.png -Under heavy load, :doc:`DiskCache ` gets are very low latency. At the -90th percentile, they are less than half the latency of Memcached. +Under heavy load, :doc:`DiskCache ` gets are low latency. At the 90th +percentile, they are less than half the latency of Memcached. Set ... diff --git a/docs/case-study-landing-page-caching.rst b/docs/case-study-landing-page-caching.rst index 14b2de0..677186e 100644 --- a/docs/case-study-landing-page-caching.rst +++ b/docs/case-study-landing-page-caching.rst @@ -75,7 +75,7 @@ the result and the other ten benefit. The maximum latency has increased however as three layers of caching and locking wrap the function. Ideally, the system would anticipate the pending expiration of the cached item -and would recompute the result in a separate thread of execution. Coordinating +and would recompute the result in a separate thread of execution. Coordinating recomputation would be a function of the number of workers, the expiration time, and the duration of computation. Fortunately, Vattani, et al. published the solution in "Optimal Probabilistic Cache Stampede Prevention" in 2015. @@ -135,7 +135,7 @@ real cache miss then causes the workers to synchronously recompute the landing page and cache the result. With no barrier in place, eleven workers cause a cache stampede. The eleven workers are composed of ten synchronous workers and one in a background thread. The best way to customize :math:`\beta` is through -experimentation, otherwise the default is very reasonable. +experimentation, otherwise the default is reasonable. :doc:`DiskCache ` provides data types and recipes for memoization and mitigation of cache stampedes. The decorators provided are composable for a diff --git a/docs/case-study-web-crawler.rst b/docs/case-study-web-crawler.rst index 982a3c8..c37e2a5 100644 --- a/docs/case-study-web-crawler.rst +++ b/docs/case-study-web-crawler.rst @@ -116,7 +116,7 @@ the crawl function and query it. >>> len(results) 99 -As an added benefit, our code also now works in parallel. For free! +As an added benefit, our code also now works in parallel. >>> results.clear() >>> from multiprocessing import Process diff --git a/docs/djangocache-benchmarks.rst b/docs/djangocache-benchmarks.rst index 4f791ff..88d1fec 100644 --- a/docs/djangocache-benchmarks.rst +++ b/docs/djangocache-benchmarks.rst @@ -102,8 +102,8 @@ Get .. image:: _static/djangocache-get.png -Under heavy load, :class:`DjangoCache ` gets are very -low latency. At the 99th percentile they are on par with the Memcached cache +Under heavy load, :class:`DjangoCache ` gets are low +latency. At the 99th percentile they are on par with the Memcached cache backend. Set @@ -157,7 +157,7 @@ Timings for memcached Total 791992 68.825s ========= ========= ========= ========= ========= ========= ========= ========= -Memcached performance is low latency and very stable. +Memcached performance is low latency and stable. ========= ========= ========= ========= ========= ========= ========= ========= Timings for redis diff --git a/docs/sf-python-2017-meetup-talk.rst b/docs/sf-python-2017-meetup-talk.rst index 214b66b..5f79000 100644 --- a/docs/sf-python-2017-meetup-talk.rst +++ b/docs/sf-python-2017-meetup-talk.rst @@ -20,7 +20,7 @@ Landscape Backends -------- -* Backends have very different designs and tradeoffs. +* Backends have different designs and tradeoffs. Frameworks @@ -165,7 +165,7 @@ SQLite * Use a context manager for isolation level management. * Pragmas tune the behavior and performance of SQLite. - * Default is very robust and slow. + * Default is robust and slow. * Use write-ahead-log so writers don't block readers. * Memory-map pages for fast lookups. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a4c3317..f704cb6 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -53,8 +53,8 @@ or install it into your site-packages easily:: :doc:`DiskCache ` is looking for a Debian package maintainer. If you can help, please open an issue in the `DiskCache Issue Tracker`_. -:doc:`DiskCache ` is looking for a CentOS/RPM package maintainer. If -you can help, please open an issue in the `DiskCache Issue Tracker`_. +:doc:`DiskCache ` is looking for a CentOS/RPM package maintainer. If you +can help, please open an issue in the `DiskCache Issue Tracker`_. .. _`DiskCache Issue Tracker`: https://github.com/grantjenks/python-diskcache/issues/ @@ -778,11 +778,11 @@ tradeoffs for accessing and storing items. the access count field stored in the cache database. On every access, the field is incremented. Every access therefore requires writing the database which slows accesses. -* ``"none"`` disables cache evictions. Caches will grow in size without - bound. Cache items will still be lazily removed if they expire. The - persistent data types, :class:`.Deque` and :class:`.Index`, use the - ``"none"`` eviction policy. For :ref:`lazy culling ` use - the :ref:`cull_limit ` setting instead. +* ``"none"`` disables cache evictions. Caches will grow without bound. Cache + items will still be lazily removed if they expire. The persistent data types, + :class:`.Deque` and :class:`.Index`, use the ``"none"`` eviction policy. For + :ref:`lazy culling ` use the :ref:`cull_limit ` + setting instead. All clients accessing the cache are expected to use the same eviction policy. The policy can be set during initialization using a keyword argument. From 912f5b6c310ae62c6bd0bf12b196b2a11fd57e63 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 2 Jul 2019 21:38:37 -0700 Subject: [PATCH 191/329] Move zero-expiration logic into memoize (rather than Cache.set) --- diskcache/core.py | 13 ++++++------- diskcache/djangocache.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index c74241e..69b1952 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -737,9 +737,6 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): When `read` is `True`, `value` should be a file-like object opened for reading in binary mode. - If `expire` is less than or equal to zero then immediately returns - `False`. - Raises :exc:`Timeout` error when database timeout occurs and `retry` is `False` (default). @@ -754,9 +751,6 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): :raises Timeout: if database timeout occurs """ - if expire is not None and expire <= 0: - return False - now = time.time() db_key, raw = self._disk.put(key) expire_time = None if expire is None else now + expire @@ -1779,6 +1773,10 @@ def memoize(self, name=None, typed=False, expire=None, tag=None): If name is set to None (default), the callable name will be determined automatically. + When expire is set to zero, function results will not be set in the + cache. Cache lookups still occur, however. Read + :doc:`case-study-landing-page-caching` for example usage. + If typed is set to True, function arguments of different types will be cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. @@ -1843,7 +1841,8 @@ def wrapper(*args, **kwargs): if result is ENOVAL: result = func(*args, **kwargs) - self.set(key, result, expire=expire, tag=tag, retry=True) + if expire is None or expire > 0: + self.set(key, result, expire, tag=tag, retry=True) return result diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index a2d863c..00a96bf 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -368,6 +368,10 @@ def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, If name is set to None (default), the callable name will be determined automatically. + When timeout is set to zero, function results will not be set in the + cache. Cache lookups still occur, however. Read + :doc:`case-study-landing-page-caching` for example usage. + If typed is set to True, function arguments of different types will be cached separately. For example, f(3) and f(3.0) will be treated as distinct calls with distinct results. @@ -407,9 +411,10 @@ def wrapper(*args, **kwargs): if result is ENOVAL: result = func(*args, **kwargs) - self.set( - key, result, timeout, version, tag=tag, retry=True, - ) + if timeout is None or timeout > 0: + self.set( + key, result, timeout, version, tag=tag, retry=True, + ) return result From 9cfe5df3fa0a3d98b522bb3426aa9934bb481b2c Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Sun, 13 Oct 2019 15:42:48 -0700 Subject: [PATCH 192/329] Provide JSONDisk with diskcache (#124) --- .gitignore | 1 + diskcache/__init__.py | 3 ++- diskcache/core.py | 29 +++++++++++++++++++++++++++++ docs/tutorial.rst | 3 ++- tests/test_core.py | 31 +------------------------------ 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 0ef1c27..c496721 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # virutalenv directories /env*/ +/.venv*/ # test files/directories /.cache/ diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 46536bb..b392164 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -6,7 +6,7 @@ """ -from .core import Cache, Disk, EmptyDirWarning, UnknownFileWarning, Timeout +from .core import Cache, Disk, EmptyDirWarning, JSONDisk, UnknownFileWarning, Timeout from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache from .persistent import Deque, Index @@ -25,6 +25,7 @@ 'EmptyDirWarning', 'FanoutCache', 'Index', + "JSONDisk", 'Lock', 'RLock', 'Timeout', diff --git a/diskcache/core.py b/diskcache/core.py index 69b1952..5454924 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -7,6 +7,7 @@ import errno import functools as ft import io +import json import os import os.path as op import pickletools @@ -361,6 +362,34 @@ def remove(self, filename): raise +class JSONDisk(Disk): + """Cache key and value (de)serialized as JSON.""" + def __init__(self, directory, compress_level=1, **kwargs): + self.compress_level = compress_level + super(JSONDisk, self).__init__(directory, **kwargs) + + def put(self, key): + json_bytes = json.dumps(key).encode('utf-8') + data = zlib.compress(json_bytes, self.compress_level) + return super(JSONDisk, self).put(data) + + def get(self, key, raw): + data = super(JSONDisk, self).get(key, raw) + return json.loads(zlib.decompress(data).decode('utf-8')) + + def store(self, value, read, key=UNKNOWN): + if not read: + json_bytes = json.dumps(value).encode('utf-8') + value = zlib.compress(json_bytes, self.compress_level) + return super(JSONDisk, self).store(value, read, key=key) + + def fetch(self, mode, filename, value, read): + data = super(JSONDisk, self).fetch(mode, filename, value, read) + if not read: + data = json.loads(zlib.decompress(data).decode('utf-8')) + return data + + class Timeout(Exception): "Database timeout expired." diff --git a/docs/tutorial.rst b/docs/tutorial.rst index f704cb6..7b65f99 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -813,7 +813,8 @@ database while values are sometimes stored separately in files. To customize serialization, you may pass in a :class:`Disk ` subclass to initialize the cache. All clients accessing the cache are expected to use the same serialization. The default implementation uses Pickle and the -example below uses compressed JSON. +example below uses compressed JSON, +available for convenience as :class:`Disk `. .. code-block:: python diff --git a/tests/test_core.py b/tests/test_core.py index acf6b3f..7e79995 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,7 +7,6 @@ import functools as ft import hashlib import io -import json import mock import os import os.path as op @@ -22,7 +21,6 @@ import time import unittest import warnings -import zlib try: import cPickle as pickle @@ -94,35 +92,8 @@ def test_disk_valueerror(): pass -class JSONDisk(diskcache.Disk): - def __init__(self, directory, compress_level=1, **kwargs): - self.compress_level = compress_level - super(JSONDisk, self).__init__(directory, **kwargs) - - def put(self, key): - json_bytes = json.dumps(key).encode('utf-8') - data = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).put(data) - - def get(self, key, raw): - data = super(JSONDisk, self).get(key, raw) - return json.loads(zlib.decompress(data).decode('utf-8')) - - def store(self, value, read, key=dc.UNKNOWN): - if not read: - json_bytes = json.dumps(value).encode('utf-8') - value = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).store(value, read, key=key) - - def fetch(self, mode, filename, value, read): - data = super(JSONDisk, self).fetch(mode, filename, value, read) - if not read: - data = json.loads(zlib.decompress(data).decode('utf-8')) - return data - - def test_custom_disk(): - with dc.Cache(disk=JSONDisk, disk_compress_level=6) as cache: + with dc.Cache(disk=dc.JSONDisk, disk_compress_level=6) as cache: values = [None, True, 0, 1.23, {}, [None] * 10000] for value in values: From 19f908bd5d6d90c4f69710b27a1cbad22eb93e31 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 13 Oct 2019 16:07:48 -0700 Subject: [PATCH 193/329] Add docs for JSONDisk. --- diskcache/__init__.py | 2 +- diskcache/core.py | 18 +++++++++++++++++- docs/api.rst | 10 ++++++++++ docs/tutorial.rst | 4 ++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index b392164..2f11ea4 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -25,7 +25,7 @@ 'EmptyDirWarning', 'FanoutCache', 'Index', - "JSONDisk", + 'JSONDisk', 'Lock', 'RLock', 'Timeout', diff --git a/diskcache/core.py b/diskcache/core.py index 5454924..0c8fd2c 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -363,26 +363,42 @@ def remove(self, filename): class JSONDisk(Disk): - """Cache key and value (de)serialized as JSON.""" + "Cache key and value using JSON serialization with zlib compression." def __init__(self, directory, compress_level=1, **kwargs): + """Initialize JSON disk instance. + + Keys and values are compressed using the zlib library. The + `compress_level` is an integer from 0 to 9 controlling the level of + compression; 1 is fastest and produces the least compression, 9 is + slowest and produces the most compression, and 0 is no compression. + + :param str directory: directory path + :param int compress_level: zlib compression level (default 1) + :param kwargs: super class arguments + + """ self.compress_level = compress_level super(JSONDisk, self).__init__(directory, **kwargs) + def put(self, key): json_bytes = json.dumps(key).encode('utf-8') data = zlib.compress(json_bytes, self.compress_level) return super(JSONDisk, self).put(data) + def get(self, key, raw): data = super(JSONDisk, self).get(key, raw) return json.loads(zlib.decompress(data).decode('utf-8')) + def store(self, value, read, key=UNKNOWN): if not read: json_bytes = json.dumps(value).encode('utf-8') value = zlib.compress(json_bytes, self.compress_level) return super(JSONDisk, self).store(value, read, key=key) + def fetch(self, mode, filename, value, read): data = super(JSONDisk, self).fetch(mode, filename, value, read) if not read: diff --git a/docs/api.rst b/docs/api.rst index 99686bb..e38f254 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -116,6 +116,16 @@ Read the :ref:`Disk tutorial ` for details. :special-members: :exclude-members: __weakref__ +JSONDisk +-------- + +Read the :ref:`Disk tutorial ` for details. + +.. autoclass:: diskcache.JSONDisk + :members: + :special-members: + :exclude-members: __weakref__ + Timeout ------- diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 7b65f99..b29322c 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -813,8 +813,8 @@ database while values are sometimes stored separately in files. To customize serialization, you may pass in a :class:`Disk ` subclass to initialize the cache. All clients accessing the cache are expected to use the same serialization. The default implementation uses Pickle and the -example below uses compressed JSON, -available for convenience as :class:`Disk `. +example below uses compressed JSON, available for convenience as +:class:`JSONDisk `. .. code-block:: python From 9bee7b9ff43f2caf6f84fc6bdf1b6d7a695c0fe8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 13 Oct 2019 16:09:20 -0700 Subject: [PATCH 194/329] Add check for DEFAULT_TIMEOUT in DjangoCache.memoize --- diskcache/djangocache.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 00a96bf..997b852 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -411,7 +411,12 @@ def wrapper(*args, **kwargs): if result is ENOVAL: result = func(*args, **kwargs) - if timeout is None or timeout > 0: + valid_timeout = ( + timeout is None + or timeout == DEFAULT_TIMEOUT + or timeout > 0 + ) + if valid_timeout: self.set( key, result, timeout, version, tag=tag, retry=True, ) From 0e273fb54f9bfec6a33e6be7cdf9a68f0ea2adaa Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 13 Oct 2019 16:59:15 -0700 Subject: [PATCH 195/329] Bump version to 4.1.0 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 2f11ea4..192524e 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -44,8 +44,8 @@ pass __title__ = 'diskcache' -__version__ = '4.0.0' -__build__ = 0x040000 +__version__ = '4.1.0' +__build__ = 0x040100 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2018 Grant Jenks' From b0451e084ea403c29980f683b8f0d8c9ac2a2dea Mon Sep 17 00:00:00 2001 From: Mengyang Li Date: Wed, 20 Nov 2019 14:11:10 -0800 Subject: [PATCH 196/329] peekleft: fix docstr typo (#132) --- diskcache/persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 961f773..9de5835 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -442,7 +442,7 @@ def peek(self): def peekleft(self): - """Peek at value at back of deque. + """Peek at value at front of deque. Faster than indexing deque at 0. From 727037065e64a698147171f9553a6ee08be02950 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Jun 2020 11:19:13 -0700 Subject: [PATCH 197/329] Bump Django to 2.2.* --- requirements.txt | 2 +- tox.ini | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 659180d..ae05547 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ coverage -django==1.11.* +django==2.2.* django_redis doc8 gj diff --git a/tox.ini b/tox.ini index 43839bf..5813859 100644 --- a/tox.ini +++ b/tox.ini @@ -4,9 +4,8 @@ skip_missing_interpreters=True [testenv] deps= - django==1.11.* - mock - pytest==4.6.* + django==2.2.* + pytest pytest-django pytest-xdist commands=python -m pytest @@ -28,9 +27,9 @@ env = DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH={PWD}:{PWD}/tests -[testenv:lint] +[testenv:pylint] deps= - django==1.11.* + django==2.2.* pylint commands=pylint diskcache From eb8033c61b59f23772801c8bfa22e269e70cb115 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Jun 2020 11:21:18 -0700 Subject: [PATCH 198/329] Drop Python 2.7 and 3.4 testing and add 3.8 --- .travis.yml | 7 +++---- appveyor.yml | 6 ++---- tox.ini | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index c67140c..6c1bd8e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,10 +4,6 @@ install: python -m pip install tox script: python -m tox -e py matrix: include: - - python: 2.7 - env: TOXENV=py27 - - python: 3.4 - env: TOXENV=py34 - python: 3.5 env: TOXENV=py35 - python: 3.6 @@ -15,6 +11,9 @@ matrix: - python: 3.7 dist: xenial env: TOXENV=py37 + - python: 3.8 + dist: xenial + env: TOXENV=py38 - python: pypy env: TOXENV=pypy - python: 3.7 diff --git a/appveyor.yml b/appveyor.yml index dc376f1..1f52a08 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,16 +2,14 @@ environment: matrix: - - PYTHON: "C:\\Python27" - - PYTHON: "C:\\Python34" - PYTHON: "C:\\Python35" - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - - PYTHON: "C:\\Python27-x64" - - PYTHON: "C:\\Python34-x64" + - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python38-x64" install: diff --git a/tox.ini b/tox.ini index 5813859..ea3996d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py27,py34,py35,py36,py37,pypy,lint +envlist=py35,py36,py37,py38,pypy,pylint skip_missing_interpreters=True [testenv] From 8130ebca2c72a90b9d25feeecfeed8efa547025f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Jun 2020 11:31:15 -0700 Subject: [PATCH 199/329] Update DjangoCache tests for Django 2.2 --- tests/test_djangocache.py | 222 +++++++++++++++++--------------------- 1 file changed, 98 insertions(+), 124 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index f70b87c..100f998 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -1,15 +1,12 @@ -# -*- coding: utf-8 -*- - # Most of this file was copied from: -# https://raw.githubusercontent.com/django/django/1.11.12/tests/cache/tests.py +# https://raw.githubusercontent.com/django/django/stable/2.2.x/tests/cache/tests.py # Unit tests for cache framework # Uses whatever cache backend is set in the test settings file. -from __future__ import unicode_literals - import copy import io import os +import pickle import re import shutil import tempfile @@ -17,11 +14,12 @@ import time import unittest import warnings +from unittest import mock from django.conf import settings from django.core import management, signals from django.core.cache import ( - DEFAULT_CACHE_ALIAS, CacheKeyWarning, cache, caches, + DEFAULT_CACHE_ALIAS, CacheKeyWarning, InvalidCacheKey, cache, caches, ) from django.core.cache.utils import make_template_fragment_key from django.db import close_old_connections, connection, connections @@ -37,15 +35,13 @@ from django.template.response import TemplateResponse from django.test import ( RequestFactory, SimpleTestCase, TestCase, TransactionTestCase, - ignore_warnings, mock, override_settings, + override_settings, ) from django.test.signals import setting_changed -from django.utils import six, timezone, translation +from django.utils import timezone, translation from django.utils.cache import ( - get_cache_key, learn_cache_key, patch_cache_control, - patch_response_headers, patch_vary_headers, + get_cache_key, learn_cache_key, patch_cache_control, patch_vary_headers, ) -from django.utils.deprecation import RemovedInDjango21Warning from django.utils.encoding import force_text from django.views.decorators.cache import cache_page @@ -55,27 +51,12 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') -############################################################################ -# GrantJ 2017-03-27 Ignore deprecation warnings. Django's metaclass magic does -# not always play well with Python 3.6. Read -# http://stackoverflow.com/questions/41343263/ for details -############################################################################ - -import warnings -warnings.filterwarnings('ignore', category=DeprecationWarning) - import django django.setup() from .models import Poll, expensive_calculation -try: # Use the same idiom as in cache backends - from django.utils.six.moves import cPickle as pickle -except ImportError: - import pickle - - # functions/classes for complex data type tests def f(): return 42 @@ -86,11 +67,17 @@ def m(n): return 24 -class Unpicklable(object): +class Unpicklable: def __getstate__(self): raise pickle.PickleError() +KEY_ERRORS_WITH_MEMCACHED_MSG = ( + 'Cache key contains characters that will cause errors if used with ' + 'memcached: %r' +) + + class UnpicklableType(object): # Unpicklable using the default pickling protocol on Python 2. __slots__ = 'a', @@ -101,17 +88,12 @@ def custom_key_func(key, key_prefix, version): return 'CUSTOM-' + '-'.join([key_prefix, str(version), key]) -def custom_key_func2(key, key_prefix, version): - "Another customized cache key function" - return '-'.join(['CUSTOM', key_prefix, str(version), key]) - - _caches_setting_base = { 'default': {}, 'prefix': {'KEY_PREFIX': 'cacheprefix{}'.format(os.getpid())}, 'v2': {'VERSION': 2}, 'custom_key': {'KEY_FUNCTION': custom_key_func}, - 'custom_key2': {'KEY_FUNCTION': custom_key_func2}, + 'custom_key2': {'KEY_FUNCTION': 'tests.test_djangocache.custom_key_func'}, 'cull': {'OPTIONS': {'MAX_ENTRIES': 30}}, 'zero_cull': {'OPTIONS': {'CULL_FREQUENCY': 0, 'MAX_ENTRIES': 30}}, } @@ -127,18 +109,16 @@ def caches_setting_for_tests(base=None, exclude=None, **params): # params -> _caches_setting_base -> base base = base or {} exclude = exclude or set() - setting = {k: base.copy() for k in _caches_setting_base.keys() if k not in exclude} + setting = {k: base.copy() for k in _caches_setting_base if k not in exclude} for key, cache_params in setting.items(): cache_params.update(_caches_setting_base[key]) cache_params.update(params) return setting -class BaseCacheTests(object): +class BaseCacheTests: # A common set of tests to apply to all cache backends - - def setUp(self): - self.factory = RequestFactory() + factory = RequestFactory() def tearDown(self): cache.clear() @@ -168,24 +148,20 @@ def test_prefix(self): self.assertEqual(caches['prefix'].get('somekey'), 'value2') def test_non_existent(self): - # Non-existent cache keys return as None/default - # get with non-existent keys + """Nonexistent cache keys return as None/default.""" self.assertIsNone(cache.get("does_not_exist")) self.assertEqual(cache.get("does_not_exist", "bang!"), "bang!") def test_get_many(self): # Multiple cache keys can be returned using get_many - cache.set('a', 'a') - cache.set('b', 'b') - cache.set('c', 'c') - cache.set('d', 'd') - self.assertDictEqual(cache.get_many(['a', 'c', 'd']), {'a': 'a', 'c': 'c', 'd': 'd'}) - self.assertDictEqual(cache.get_many(['a', 'b', 'e']), {'a': 'a', 'b': 'b'}) + cache.set_many({'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd'}) + self.assertEqual(cache.get_many(['a', 'c', 'd']), {'a': 'a', 'c': 'c', 'd': 'd'}) + self.assertEqual(cache.get_many(['a', 'b', 'e']), {'a': 'a', 'b': 'b'}) + self.assertEqual(cache.get_many(iter(['a', 'b', 'e'])), {'a': 'a', 'b': 'b'}) def test_delete(self): # Cache keys can be deleted - cache.set("key1", "spam") - cache.set("key2", "eggs") + cache.set_many({'key1': 'spam', 'key2': 'eggs'}) self.assertEqual(cache.get("key1"), "spam") cache.delete("key1") self.assertIsNone(cache.get("key1")) @@ -286,23 +262,6 @@ def test_cache_read_for_model_instance_with_deferred(self): # We only want the default expensive calculation run on creation and set self.assertEqual(expensive_calculation.num_runs, runs_before_cache_read) - def test_touch(self): - # cache.touch() updates the timeout. - cache.set('expire1', 'very quickly', timeout=1) - self.assertTrue(cache.touch('expire1', timeout=2)) - time.sleep(1) - self.assertTrue(cache.has_key('expire1')) - time.sleep(2) - self.assertFalse(cache.has_key('expire1')) - - # cache.touch() works without the timeout argument. - cache.set('expire1', 'very quickly', timeout=1) - self.assertTrue(cache.touch('expire1')) - time.sleep(2) - self.assertTrue(cache.has_key('expire1')) - - self.assertFalse(cache.touch('nonexistent')) - def test_expiration(self): # Cache values can be set to expire cache.set('expire1', 'very quickly', 1) @@ -316,6 +275,23 @@ def test_expiration(self): self.assertEqual(cache.get("expire2"), "newvalue") self.assertFalse(cache.has_key("expire3")) + def test_touch(self): + # cache.touch() updates the timeout. + cache.set('expire1', 'very quickly', timeout=1) + self.assertIs(cache.touch('expire1', timeout=4), True) + time.sleep(2) + self.assertTrue(cache.has_key('expire1')) + time.sleep(3) + self.assertFalse(cache.has_key('expire1')) + + # cache.touch() works without the timeout argument. + cache.set('expire1', 'very quickly', timeout=1) + self.assertIs(cache.touch('expire1'), True) + time.sleep(2) + self.assertTrue(cache.has_key('expire1')) + + self.assertIs(cache.touch('nonexistent'), False) + def test_unicode(self): # Unicode values can be cached stuff = { @@ -326,21 +302,24 @@ def test_unicode(self): } # Test `set` for (key, value) in stuff.items(): - cache.set(key, value) - self.assertEqual(cache.get(key), value) + with self.subTest(key=key): + cache.set(key, value) + self.assertEqual(cache.get(key), value) # Test `add` for (key, value) in stuff.items(): - cache.delete(key) - cache.add(key, value) - self.assertEqual(cache.get(key), value) + with self.subTest(key=key): + cache.delete(key) + cache.add(key, value) + self.assertEqual(cache.get(key), value) # Test `set_many` for (key, value) in stuff.items(): cache.delete(key) cache.set_many(stuff) for (key, value) in stuff.items(): - self.assertEqual(cache.get(key), value) + with self.subTest(key=key): + self.assertEqual(cache.get(key), value) def test_binary_string(self): # Binary strings should be cacheable @@ -372,6 +351,11 @@ def test_set_many(self): self.assertEqual(cache.get("key1"), "spam") self.assertEqual(cache.get("key2"), "eggs") + def test_set_many_returns_empty_list_on_success(self): + """set_many() returns an empty list when all keys are inserted.""" + failing_keys = cache.set_many({'key1': 'spam', 'key2': 'eggs'}) + self.assertEqual(failing_keys, []) + def test_set_many_expiration(self): # set_many takes a second ``timeout`` parameter cache.set_many({"key1": "spam", "key2": "eggs"}, 1) @@ -381,9 +365,7 @@ def test_set_many_expiration(self): def test_delete_many(self): # Multiple keys can be deleted using delete_many - cache.set("key1", "spam") - cache.set("key2", "eggs") - cache.set("key3", "ham") + cache.set_many({'key1': 'spam', 'key2': 'eggs', 'key3': 'ham'}) cache.delete_many(["key1", "key2"]) self.assertIsNone(cache.get("key1")) self.assertIsNone(cache.get("key2")) @@ -391,8 +373,7 @@ def test_delete_many(self): def test_clear(self): # The cache can be emptied using clear - cache.set("key1", "spam") - cache.set("key2", "eggs") + cache.set_many({'key1': 'spam', 'key2': 'eggs'}) cache.clear() self.assertIsNone(cache.get("key1")) self.assertIsNone(cache.get("key2")) @@ -478,10 +459,10 @@ def test_zero_cull(self): def _perform_invalid_key_test(self, key, expected_warning): """ - All the builtin backends (except memcached, see below) should warn on - keys that would be refused by memcached. This encourages portable - caching code without making it too difficult to use production backends - with more liberal key rules. Refs #6447. + All the builtin backends should warn (except memcached that should + error) on keys that would be refused by memcached. This encourages + portable caching code without making it too difficult to use production + backends with more liberal key rules. Refs #6447. """ # mimic custom ``make_key`` method being defined since the default will # never show the below warnings @@ -492,23 +473,16 @@ def func(key, *args): cache.key_func = func try: - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + with self.assertWarns(CacheKeyWarning) as cm: cache.set(key, 'value') - self.assertEqual(len(w), 1) - self.assertIsInstance(w[0].message, CacheKeyWarning) - self.assertEqual(str(w[0].message.args[0]), expected_warning) + self.assertEqual(str(cm.warning), expected_warning) finally: cache.key_func = old_func def test_invalid_key_characters(self): # memcached doesn't allow whitespace or control characters in keys. key = 'key with spaces and 清' - expected_warning = ( - "Cache key contains characters that will cause errors if used " - "with memcached: %r" % key - ) - self._perform_invalid_key_test(key, expected_warning) + self._perform_invalid_key_test(key, KEY_ERRORS_WITH_MEMCACHED_MSG % key) def test_invalid_key_length(self): # memcached limits key length to 250. @@ -678,43 +652,43 @@ def test_cache_versioning_incr_decr(self): def test_cache_versioning_get_set_many(self): # set, using default version = 1 cache.set_many({'ford1': 37, 'arthur1': 42}) - self.assertDictEqual(cache.get_many(['ford1', 'arthur1']), {'ford1': 37, 'arthur1': 42}) - self.assertDictEqual(cache.get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) - self.assertDictEqual(cache.get_many(['ford1', 'arthur1'], version=2), {}) + self.assertEqual(cache.get_many(['ford1', 'arthur1']), {'ford1': 37, 'arthur1': 42}) + self.assertEqual(cache.get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) + self.assertEqual(cache.get_many(['ford1', 'arthur1'], version=2), {}) - self.assertDictEqual(caches['v2'].get_many(['ford1', 'arthur1']), {}) - self.assertDictEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=2), {}) + self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1']), {}) + self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) + self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=2), {}) # set, default version = 1, but manually override version = 2 cache.set_many({'ford2': 37, 'arthur2': 42}, version=2) - self.assertDictEqual(cache.get_many(['ford2', 'arthur2']), {}) - self.assertDictEqual(cache.get_many(['ford2', 'arthur2'], version=1), {}) - self.assertDictEqual(cache.get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) + self.assertEqual(cache.get_many(['ford2', 'arthur2']), {}) + self.assertEqual(cache.get_many(['ford2', 'arthur2'], version=1), {}) + self.assertEqual(cache.get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford2', 'arthur2']), {'ford2': 37, 'arthur2': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=1), {}) - self.assertDictEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) + self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2']), {'ford2': 37, 'arthur2': 42}) + self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=1), {}) + self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) # v2 set, using default version = 2 caches['v2'].set_many({'ford3': 37, 'arthur3': 42}) - self.assertDictEqual(cache.get_many(['ford3', 'arthur3']), {}) - self.assertDictEqual(cache.get_many(['ford3', 'arthur3'], version=1), {}) - self.assertDictEqual(cache.get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) + self.assertEqual(cache.get_many(['ford3', 'arthur3']), {}) + self.assertEqual(cache.get_many(['ford3', 'arthur3'], version=1), {}) + self.assertEqual(cache.get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford3', 'arthur3']), {'ford3': 37, 'arthur3': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=1), {}) - self.assertDictEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) + self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3']), {'ford3': 37, 'arthur3': 42}) + self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=1), {}) + self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) # v2 set, default version = 2, but manually override version = 1 caches['v2'].set_many({'ford4': 37, 'arthur4': 42}, version=1) - self.assertDictEqual(cache.get_many(['ford4', 'arthur4']), {'ford4': 37, 'arthur4': 42}) - self.assertDictEqual(cache.get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) - self.assertDictEqual(cache.get_many(['ford4', 'arthur4'], version=2), {}) + self.assertEqual(cache.get_many(['ford4', 'arthur4']), {'ford4': 37, 'arthur4': 42}) + self.assertEqual(cache.get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) + self.assertEqual(cache.get_many(['ford4', 'arthur4'], version=2), {}) - self.assertDictEqual(caches['v2'].get_many(['ford4', 'arthur4']), {}) - self.assertDictEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) - self.assertDictEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=2), {}) + self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4']), {}) + self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) + self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=2), {}) def test_incr_version(self): cache.set('answer', 42, version=2) @@ -801,13 +775,13 @@ def test_cache_write_unpicklable_object(self): get_cache_data = fetch_middleware.process_request(request) self.assertIsNotNone(get_cache_data) - self.assertEqual(get_cache_data.content, content.encode('utf-8')) + self.assertEqual(get_cache_data.content, content.encode()) self.assertEqual(get_cache_data.cookies, response.cookies) update_middleware.process_response(request, get_cache_data) get_cache_data = fetch_middleware.process_request(request) self.assertIsNotNone(get_cache_data) - self.assertEqual(get_cache_data.content, content.encode('utf-8')) + self.assertEqual(get_cache_data.content, content.encode()) self.assertEqual(get_cache_data.cookies, response.cookies) def test_add_fail_on_pickleerror(self): @@ -838,10 +812,11 @@ def test_get_or_set_callable_returning_none(self): self.assertEqual(cache.get('mykey', 'default'), 'default') def test_get_or_set_version(self): + msg = "get_or_set() missing 1 required positional argument: 'default'" cache.get_or_set('brian', 1979, version=2) - with self.assertRaises(TypeError): + with self.assertRaisesMessage(TypeError, msg): cache.get_or_set('brian') - with self.assertRaises(TypeError): + with self.assertRaisesMessage(TypeError, msg): cache.get_or_set('brian', version=1) self.assertIsNone(cache.get('brian', version=1)) self.assertEqual(cache.get_or_set('brian', 42, version=1), 42) @@ -856,15 +831,14 @@ def test_get_or_set_racing(self): self.assertEqual(cache.get_or_set('key', 'default'), 'default') -class PicklingSideEffect(object): +class PicklingSideEffect: def __init__(self, cache): self.cache = cache self.locked = False def __getstate__(self): - if self.cache._lock.active_writers: - self.locked = True + self.locked = self.cache._lock.locked() return {} @@ -874,9 +848,9 @@ def __getstate__(self): class DiskCacheTests(BaseCacheTests, TestCase): "Specific test cases for diskcache.DjangoCache." def setUp(self): - super(DiskCacheTests, self).setUp() + super().setUp() self.dirname = tempfile.mkdtemp() - # Cache location cannot be modified through override_settings / modify_settings, + # Caches location cannot be modified through override_settings / modify_settings, # hence settings are manipulated directly here and the setting_changed signal # is triggered manually. for cache_params in settings.CACHES.values(): @@ -884,7 +858,7 @@ def setUp(self): setting_changed.send(self.__class__, setting='CACHES', enter=False) def tearDown(self): - super(DiskCacheTests, self).tearDown() + super().tearDown() cache.close() shutil.rmtree(self.dirname, ignore_errors=True) From e8f965945e5e82353080bb3454de68084c7e34db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Sat, 27 Jun 2020 20:35:13 +0200 Subject: [PATCH 200/329] tests: Use unittest.mock over external mock (#144) Mocking support is built-in since Python 3.3. Use it over external mock when available. This helps distributions that are phasing Python 2 out and would like to remove mock package as part of that. Co-authored-by: Grant Jenks --- requirements.txt | 2 +- tests/test_core.py | 6 +++++- tests/test_deque.py | 6 +++++- tests/test_fanout.py | 6 +++++- tests/test_index.py | 6 +++++- tests/test_recipes.py | 6 +++++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index ae05547..ef73654 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ django==2.2.* django_redis doc8 gj -mock +mock;python_version<"3.3" nose pylibmc pylint diff --git a/tests/test_core.py b/tests/test_core.py index 7e79995..1bc4e15 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,7 +7,6 @@ import functools as ft import hashlib import io -import mock import os import os.path as op import pytest @@ -27,6 +26,11 @@ except: import pickle +try: + from unittest import mock +except: + import mock + import diskcache import diskcache as dc diff --git a/tests/test_deque.py b/tests/test_deque.py index 640cee2..a00b7fc 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -1,11 +1,15 @@ "Test diskcache.persistent.Deque." import functools as ft -import mock import pickle import pytest import shutil +try: + from unittest import mock +except: + import mock + import diskcache as dc from diskcache.core import ENOVAL diff --git a/tests/test_fanout.py b/tests/test_fanout.py index a62f8a2..10919aa 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -7,7 +7,6 @@ import functools as ft import hashlib import io -import mock import os import os.path as op import pytest @@ -21,6 +20,11 @@ import time import warnings +try: + from unittest import mock +except: + import mock + try: import cPickle as pickle except: diff --git a/tests/test_index.py b/tests/test_index.py index f4232c8..308f894 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,12 +1,16 @@ "Test diskcache.persistent.Index." import functools as ft -import mock import pickle import pytest import shutil import sys +try: + from unittest import mock +except: + import mock + import diskcache as dc diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 35de332..4d908c7 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -1,12 +1,16 @@ "Test diskcache.recipes." import diskcache as dc -import mock import pytest import shutil import threading import time +try: + from unittest import mock +except: + import mock + @pytest.fixture def cache(): From 1794c74ffcb9a5d4a174c8beac46f687884d0956 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 27 Jun 2020 11:40:21 -0700 Subject: [PATCH 201/329] Use unittest.mock in tests --- requirements.txt | 1 - tests/test_core.py | 13 ++----------- tests/test_deque.py | 5 +---- tests/test_djangocache.py | 1 + tests/test_fanout.py | 11 ++--------- tests/test_index.py | 5 +---- tests/test_recipes.py | 5 +---- 7 files changed, 8 insertions(+), 33 deletions(-) diff --git a/requirements.txt b/requirements.txt index ef73654..6c1a277 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ django==2.2.* django_redis doc8 gj -mock;python_version<"3.3" nose pylibmc pylint diff --git a/tests/test_core.py b/tests/test_core.py index 1bc4e15..7c38874 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,7 +1,5 @@ "Test diskcache.core.Cache." -from __future__ import print_function - import collections as co import errno import functools as ft @@ -9,6 +7,7 @@ import io import os import os.path as op +import pickle import pytest import random import shutil @@ -21,15 +20,7 @@ import unittest import warnings -try: - import cPickle as pickle -except: - import pickle - -try: - from unittest import mock -except: - import mock +from unittest import mock import diskcache import diskcache as dc diff --git a/tests/test_deque.py b/tests/test_deque.py index a00b7fc..ddf2338 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -5,10 +5,7 @@ import pytest import shutil -try: - from unittest import mock -except: - import mock +from unittest import mock import diskcache as dc from diskcache.core import ENOVAL diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 100f998..84d2f95 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -14,6 +14,7 @@ import time import unittest import warnings + from unittest import mock from django.conf import settings diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 10919aa..f5d9b70 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -9,6 +9,7 @@ import io import os import os.path as op +import pickle import pytest import random import shutil @@ -20,15 +21,7 @@ import time import warnings -try: - from unittest import mock -except: - import mock - -try: - import cPickle as pickle -except: - import pickle +from unittest import mock import diskcache as dc diff --git a/tests/test_index.py b/tests/test_index.py index 308f894..ef2a0d0 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -6,10 +6,7 @@ import shutil import sys -try: - from unittest import mock -except: - import mock +from unittest import mock import diskcache as dc diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 4d908c7..b26a239 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -6,10 +6,7 @@ import threading import time -try: - from unittest import mock -except: - import mock +from unittest import mock @pytest.fixture From 7e917912f3d1eb1b5263110687b25ce8d231505a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 5 Jul 2020 14:45:46 -0700 Subject: [PATCH 202/329] Run pylint in 3.8 venv --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6c1bd8e..a2ebaf2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,6 @@ matrix: env: TOXENV=py38 - python: pypy env: TOXENV=pypy - - python: 3.7 + - python: 3.8 dist: xenial - env: TOXENV=lint + env: TOXENV=pylint From de987e377616806303a166f27715c468a4421b86 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 5 Jul 2020 14:46:01 -0700 Subject: [PATCH 203/329] Drop pypy for Django 2 support --- .travis.yml | 2 -- tox.ini | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index a2ebaf2..5e5c760 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,8 +14,6 @@ matrix: - python: 3.8 dist: xenial env: TOXENV=py38 - - python: pypy - env: TOXENV=pypy - python: 3.8 dist: xenial env: TOXENV=pylint diff --git a/tox.ini b/tox.ini index ea3996d..b2b6d45 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py35,py36,py37,py38,pypy,pylint +envlist=py35,py36,py37,py38,pylint skip_missing_interpreters=True [testenv] From a6212df9d45b7df157044b1b1720cd997ef0f69f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 21:59:29 -0700 Subject: [PATCH 204/329] Remove unused "env" setting from pytest section --- tox.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/tox.ini b/tox.ini index b2b6d45..8e9c2e0 100644 --- a/tox.ini +++ b/tox.ini @@ -23,9 +23,6 @@ addopts= --ignore tests/plot.py norecursedirs=site-packages testpaths=docs diskcache tests -env = - DJANGO_SETTINGS_MODULE=tests.settings - PYTHONPATH={PWD}:{PWD}/tests [testenv:pylint] deps= From 82b45e8e342ee597dba56f0a42c0d6088b0c2037 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 21:59:59 -0700 Subject: [PATCH 205/329] Remove nose references --- requirements.txt | 1 - tests/test_core.py | 5 ----- tests/test_fanout.py | 5 ----- 3 files changed, 11 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6c1a277..835f48a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ django==2.2.* django_redis doc8 gj -nose pylibmc pylint pytest diff --git a/tests/test_core.py b/tests/test_core.py index 7c38874..36311e5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1464,8 +1464,3 @@ def fibrec(num): assert hits2 == (hits1 + count) assert misses2 == misses1 - - -if __name__ == '__main__': - import nose - nose.runmodule() diff --git a/tests/test_fanout.py b/tests/test_fanout.py index f5d9b70..390961b 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -685,8 +685,3 @@ def test_custom_filename_disk(): assert content == str(count) * int(1e5) shutil.rmtree(cache.directory, ignore_errors=True) - - -if __name__ == '__main__': - import nose - nose.runmodule() From 83fe89c80371278d45917b6adde58c179fc5f5e2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 22:01:36 -0700 Subject: [PATCH 206/329] Fixes for pylint in Python 3.8 --- diskcache/core.py | 32 ++++++++++++++------------------ diskcache/djangocache.py | 4 ++-- diskcache/persistent.py | 2 +- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 0c8fd2c..0cf95a1 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -180,7 +180,7 @@ def put(self, key): :return: (database key, raw boolean) pair """ - # pylint: disable=bad-continuation,unidiomatic-typecheck + # pylint: disable=unidiomatic-typecheck type_key = type(key) if type_key is BytesType: @@ -378,17 +378,17 @@ def __init__(self, directory, compress_level=1, **kwargs): """ self.compress_level = compress_level - super(JSONDisk, self).__init__(directory, **kwargs) + super().__init__(directory, **kwargs) def put(self, key): json_bytes = json.dumps(key).encode('utf-8') data = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).put(data) + return super().put(data) def get(self, key, raw): - data = super(JSONDisk, self).get(key, raw) + data = super().get(key, raw) return json.loads(zlib.decompress(data).decode('utf-8')) @@ -396,11 +396,11 @@ def store(self, value, read, key=UNKNOWN): if not read: json_bytes = json.dumps(value).encode('utf-8') value = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).store(value, read, key=key) + return super().store(value, read, key=key) def fetch(self, mode, filename, value, read): - data = super(JSONDisk, self).fetch(mode, filename, value, read) + data = super().fetch(mode, filename, value, read) if not read: data = json.loads(zlib.decompress(data).decode('utf-8')) return data @@ -448,7 +448,6 @@ def args_to_key(base, args, kwargs, typed): class Cache(object): "Disk and file backed cache." - # pylint: disable=bad-continuation def __init__(self, directory=None, timeout=60, disk=Disk, **settings): """Initialize cache instance. @@ -461,7 +460,7 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): try: assert issubclass(disk, Disk) except (TypeError, AssertionError): - raise ValueError('disk must subclass diskcache.Disk') + raise ValueError('disk must subclass diskcache.Disk') from None if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') @@ -482,7 +481,7 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): error.errno, 'Cache directory "%s" does not exist' ' and could not be created' % self._directory - ) + ) from None sql = self._sql_retry @@ -756,7 +755,7 @@ def _transact(self, retry=False, filename=None): continue if filename is not None: _disk_remove(filename) - raise Timeout + raise Timeout from None try: yield sql, filenames.append @@ -1609,8 +1608,7 @@ def pull(self, prefix=None, default=(None, None), side='front', if error.errno == errno.ENOENT: # Key was deleted before we could retrieve result. continue - else: - raise + raise finally: if name is not None: self._disk.remove(name) @@ -1719,8 +1717,7 @@ def peek(self, prefix=None, default=(None, None), side='front', if error.errno == errno.ENOENT: # Key was deleted before we could retrieve result. continue - else: - raise + raise finally: if name is not None: self._disk.remove(name) @@ -1794,8 +1791,7 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): if error.errno == errno.ENOENT: # Key was deleted before we could retrieve result. continue - else: - raise + raise break if expire_time and tag: @@ -2162,7 +2158,7 @@ def cull(self, retry=False): for filename, in rows: cleanup(filename) except Timeout: - raise Timeout(count) + raise Timeout(count) from None return count @@ -2215,7 +2211,7 @@ def _select_delete(self, select, args, row_index=0, arg_index=0, cleanup(row[-1]) except Timeout: - raise Timeout(count) + raise Timeout(count) from None return count diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 997b852..329b966 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -22,7 +22,7 @@ def __init__(self, directory, params): :param dict params: cache parameters """ - super(DjangoCache, self).__init__(params) + super().__init__(params) shards = params.get('SHARDS', 8) timeout = params.get('DATABASE_TIMEOUT', 0.010) options = params.get('OPTIONS', {}) @@ -228,7 +228,7 @@ def incr(self, key, delta=1, version=None, default=None, retry=True): try: return self._cache.incr(key, delta, default, retry) except KeyError: - raise ValueError("Key '%s' not found" % key) + raise ValueError("Key '%s' not found" % key) from None def decr(self, key, delta=1, version=None, default=None, retry=True): diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 9de5835..76eb9a2 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -915,7 +915,7 @@ def popitem(self, last=True): :raises KeyError: if index is empty """ - # pylint: disable=arguments-differ + # pylint: disable=arguments-differ,unbalanced-tuple-unpacking _cache = self._cache with _cache.transact(retry=True): From e197a93e60a10fb8ed6030fc6b556b122a664573 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 22:25:17 -0700 Subject: [PATCH 207/329] Update 2019 date references to 2020 --- LICENSE | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index 3259b98..d31985f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016-2019 Grant Jenks +Copyright 2016-2020 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/README.rst b/README.rst index 57b7a2e..ba53988 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ DiskCache: Disk Backed Cache `DiskCache`_ is an Apache2 licensed disk and file backed cache library, written in pure-Python, and compatible with Django. -The cloud-based computing of 2019 puts a premium on memory. Gigabytes of empty +The cloud-based computing of 2020 puts a premium on memory. Gigabytes of empty space is left on disks as processes vie for memory. Among these processes is Memcached (and sometimes Redis) which is used as a cache. Wouldn't it be nice to leverage empty disk space for caching? @@ -388,7 +388,7 @@ Reference License ------- -Copyright 2016-2019 Grant Jenks +Copyright 2016-2020 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/docs/conf.py b/docs/conf.py index 683dd3e..2556188 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ # General information about the project. project = u'DiskCache' -copyright = u'2019, Grant Jenks' +copyright = u'2020, Grant Jenks' author = u'Grant Jenks' # The version info for the project you're documenting, acts as replacement for From 2ba5b7985d17141afa7fb5fb3bf5913b0e496fe6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 22:44:32 -0700 Subject: [PATCH 208/329] Update references to Python 2 support --- README.rst | 4 ++-- docs/development.rst | 4 +--- setup.py | 5 +---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index ba53988..847b2cf 100644 --- a/README.rst +++ b/README.rst @@ -77,8 +77,8 @@ Features - Thread-safe and process-safe - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction -- Developed on Python 3.7 -- Tested on CPython 2.7, 3.4, 3.5, 3.6, 3.7 and PyPy +- Developed on Python 3.8 +- Tested on CPython 3.5, 3.6, 3.7, 3.8 - Tested on Linux, Mac OS X, and Windows - Tested using Travis CI and AppVeyor CI diff --git a/docs/development.rst b/docs/development.rst index 6d16d44..828c3be 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -67,12 +67,10 @@ Testing :doc:`DiskCache ` currently tests against five versions of Python: -* CPython 2.7 -* CPython 3.4 * CPython 3.5 * CPython 3.6 * CPython 3.7 -* PyPy2 +* CPython 3.8 Testing uses `tox `_. If you don't want to install all the development requirements, then, after downloading, you can diff --git a/setup.py b/setup.py index 90dc280..d9be963 100644 --- a/setup.py +++ b/setup.py @@ -38,14 +38,11 @@ def run_tests(self): 'License :: OSI Approved :: Apache Software License', 'Natural Language :: English', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', ), ) From 4497cfc85197d57298120dbd238d263bfb9e9557 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 22:58:07 -0700 Subject: [PATCH 209/329] Remove Python 2 shims --- diskcache/core.py | 68 +++++++++++------------------------------ diskcache/fanout.py | 29 ++---------------- diskcache/persistent.py | 25 +++------------ diskcache/recipes.py | 17 ++--------- 4 files changed, 27 insertions(+), 112 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 0cf95a1..1ec6acc 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -10,6 +10,7 @@ import json import os import os.path as op +import pickle import pickletools import sqlite3 import struct @@ -20,42 +21,9 @@ import warnings import zlib -############################################################################ -# BEGIN Python 2/3 Shims -############################################################################ - -if sys.hexversion < 0x03000000: - import cPickle as pickle # pylint: disable=import-error - # ISSUE #25 Fix for http://bugs.python.org/issue10211 - from cStringIO import StringIO as BytesIO # pylint: disable=import-error - from thread import get_ident # pylint: disable=import-error,no-name-in-module - TextType = unicode # pylint: disable=invalid-name,undefined-variable - BytesType = str - INT_TYPES = int, long # pylint: disable=undefined-variable - range = xrange # pylint: disable=redefined-builtin,invalid-name,undefined-variable - io_open = io.open # pylint: disable=invalid-name -else: - import pickle - from io import BytesIO # pylint: disable=ungrouped-imports - from threading import get_ident - TextType = str - BytesType = bytes - INT_TYPES = (int,) - io_open = open # pylint: disable=invalid-name - def full_name(func): "Return full name of `func` by adding the module and function name." - try: - # The __qualname__ attribute is only available in Python 3.3 and later. - # GrantJ 2019-03-29 Remove after support for Python 2 is dropped. - name = func.__qualname__ - except AttributeError: - name = func.__name__ - return func.__module__ + '.' + name - -############################################################################ -# END Python 2/3 Shims -############################################################################ + return func.__module__ + '.' + func.__qualname__ try: WindowsError @@ -164,9 +132,9 @@ def hash(self, key): if type_disk_key is sqlite3.Binary: return zlib.adler32(disk_key) & mask - elif type_disk_key is TextType: + elif type_disk_key is str: return zlib.adler32(disk_key.encode('utf-8')) & mask # pylint: disable=no-member - elif type_disk_key in INT_TYPES: + elif type_disk_key is int: return disk_key % mask else: assert type_disk_key is float @@ -183,10 +151,10 @@ def put(self, key): # pylint: disable=unidiomatic-typecheck type_key = type(key) - if type_key is BytesType: + if type_key is bytes: return sqlite3.Binary(key), True - elif ((type_key is TextType) - or (type_key in INT_TYPES + elif ((type_key is str) + or (type_key is int and -9223372036854775808 <= key <= 9223372036854775807) or (type_key is float)): return key, True @@ -206,9 +174,9 @@ def get(self, key, raw): """ # pylint: disable=no-self-use,unidiomatic-typecheck if raw: - return BytesType(key) if type(key) is sqlite3.Binary else key + return bytes(key) if type(key) is sqlite3.Binary else key else: - return pickle.load(BytesIO(key)) + return pickle.load(io.BytesIO(key)) def store(self, value, read, key=UNKNOWN): @@ -225,12 +193,12 @@ def store(self, value, read, key=UNKNOWN): type_value = type(value) min_file_size = self.min_file_size - if ((type_value is TextType and len(value) < min_file_size) - or (type_value in INT_TYPES + if ((type_value is str and len(value) < min_file_size) + or (type_value is int and -9223372036854775808 <= value <= 9223372036854775807) or (type_value is float)): return 0, MODE_RAW, None, value - elif type_value is BytesType: + elif type_value is bytes: if len(value) < min_file_size: return 0, MODE_RAW, None, sqlite3.Binary(value) else: @@ -240,10 +208,10 @@ def store(self, value, read, key=UNKNOWN): writer.write(value) return len(value), MODE_BINARY, filename, None - elif type_value is TextType: + elif type_value is str: filename, full_path = self.filename(key, value) - with io_open(full_path, 'w', encoding='UTF-8') as writer: + with open(full_path, 'w', encoding='UTF-8') as writer: writer.write(value) size = op.getsize(full_path) @@ -286,7 +254,7 @@ def fetch(self, mode, filename, value, read): """ # pylint: disable=no-self-use,unidiomatic-typecheck if mode == MODE_RAW: - return BytesType(value) if type(value) is sqlite3.Binary else value + return bytes(value) if type(value) is sqlite3.Binary else value elif mode == MODE_BINARY: if read: return open(op.join(self._directory, filename), 'rb') @@ -295,14 +263,14 @@ def fetch(self, mode, filename, value, read): return reader.read() elif mode == MODE_TEXT: full_path = op.join(self._directory, filename) - with io_open(full_path, 'r', encoding='UTF-8') as reader: + with open(full_path, 'r', encoding='UTF-8') as reader: return reader.read() elif mode == MODE_PICKLE: if value is None: with open(op.join(self._directory, filename), 'rb') as reader: return pickle.load(reader) else: - return pickle.load(BytesIO(value)) + return pickle.load(io.BytesIO(value)) def filename(self, key=UNKNOWN, value=UNKNOWN): @@ -738,7 +706,7 @@ def _transact(self, retry=False, filename=None): sql = self._sql filenames = [] _disk_remove = self._disk.remove - tid = get_ident() + tid = threading.get_ident() txn_id = self._txn_id if tid == txn_id: diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 8a0a722..1a59aa2 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -1,5 +1,6 @@ "Fanout cache automatically shards keys and values." +import functools import itertools as it import operator import os.path as op @@ -11,17 +12,6 @@ from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout from .persistent import Deque, Index -############################################################################ -# BEGIN Python 2/3 Shims -############################################################################ - -if sys.hexversion >= 0x03000000: - from functools import reduce - -############################################################################ -# END Python 2/3 Shims -############################################################################ - class FanoutCache(object): "Cache that shards keys and values." @@ -383,7 +373,7 @@ def check(self, fix=False, retry=False): """ warnings = (shard.check(fix, retry) for shard in self._shards) - return reduce(operator.iadd, warnings, []) + return functools.reduce(operator.iadd, warnings, []) def expire(self, retry=False): @@ -661,17 +651,4 @@ def index(self, name): return temp -############################################################################ -# BEGIN Python 2/3 Shims -############################################################################ - -if sys.hexversion < 0x03000000: - import types - memoize_func = Cache.__dict__['memoize'] # pylint: disable=invalid-name - FanoutCache.memoize = types.MethodType(memoize_func, None, FanoutCache) -else: - FanoutCache.memoize = Cache.memoize - -############################################################################ -# END Python 2/3 Shims -############################################################################ +FanoutCache.memoize = Cache.memoize diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 76eb9a2..5047df5 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -6,29 +6,12 @@ import sys from collections import OrderedDict +from collections.abc import MutableMapping, Sequence +from collections.abc import KeysView, ValuesView, ItemsView from contextlib import contextmanager from shutil import rmtree -from .core import BytesType, Cache, ENOVAL, TextType - -############################################################################ -# BEGIN Python 2/3 Shims -############################################################################ - -try: - from collections.abc import MutableMapping, Sequence - from collections.abc import KeysView, ValuesView, ItemsView -except ImportError: - from collections import MutableMapping, Sequence - from collections import KeysView, ValuesView, ItemsView - -if sys.hexversion < 0x03000000: - from itertools import izip as zip # pylint: disable=redefined-builtin,no-name-in-module,ungrouped-imports - range = xrange # pylint: disable=redefined-builtin,invalid-name,undefined-variable - -############################################################################ -# END Python 2/3 Shims -############################################################################ +from .core import Cache, ENOVAL def _make_compare(seq_op, doc): @@ -699,7 +682,7 @@ def __init__(self, *args, **kwargs): 4 """ - if args and isinstance(args[0], (BytesType, TextType)): + if args and isinstance(args[0], (bytes, str)): directory = args[0] args = args[1:] else: diff --git a/diskcache/recipes.py b/diskcache/recipes.py index fb64250..010f1f2 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -12,19 +12,6 @@ from .core import ENOVAL, args_to_key, full_name -############################################################################ -# BEGIN Python 2/3 Shims -############################################################################ - -if sys.hexversion < 0x03000000: - from thread import get_ident # pylint: disable=import-error -else: - from threading import get_ident - -############################################################################ -# END Python 2/3 Shims -############################################################################ - class Averager(object): """Recipe for calculating a running average. @@ -139,7 +126,7 @@ def __init__(self, cache, key, expire=None, tag=None): def acquire(self): "Acquire lock by incrementing count using spin-lock algorithm." pid = os.getpid() - tid = get_ident() + tid = threading.get_ident() pid_tid = '{}-{}'.format(pid, tid) while True: @@ -156,7 +143,7 @@ def acquire(self): def release(self): "Release lock by decrementing count." pid = os.getpid() - tid = get_ident() + tid = threading.get_ident() pid_tid = '{}-{}'.format(pid, tid) with self._cache.transact(retry=True): From 8e0f545a986c8dbb514aaa3c526472e1961934b6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 22 Aug 2020 23:13:45 -0700 Subject: [PATCH 210/329] Add flake8 to linters and fix issues --- diskcache/__init__.py | 6 ++++-- diskcache/core.py | 22 +++++++++++++++------- diskcache/fanout.py | 3 +-- diskcache/persistent.py | 5 +++-- diskcache/recipes.py | 7 +++++-- tox.ini | 13 +++++++++++-- 6 files changed, 39 insertions(+), 17 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 192524e..a301f26 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -6,7 +6,9 @@ """ -from .core import Cache, Disk, EmptyDirWarning, JSONDisk, UnknownFileWarning, Timeout +from .core import ( + Cache, Disk, EmptyDirWarning, JSONDisk, UnknownFileWarning, Timeout +) from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache from .persistent import Deque, Index @@ -37,7 +39,7 @@ ] try: - from .djangocache import DjangoCache # pylint: disable=wrong-import-position + from .djangocache import DjangoCache # noqa __all__.append('DjangoCache') except Exception: # pylint: disable=broad-except # Django not installed or not setup so ignore. diff --git a/diskcache/core.py b/diskcache/core.py index 1ec6acc..4317160 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -14,23 +14,25 @@ import pickletools import sqlite3 import struct -import sys import tempfile import threading import time import warnings import zlib + def full_name(func): "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." def __new__(cls, name): @@ -39,6 +41,7 @@ def __new__(cls, name): def __repr__(self): return '%s' % self[0] + DBNAME = 'cache.db' ENOVAL = Constant('ENOVAL') UNKNOWN = Constant('UNKNOWN') @@ -133,7 +136,7 @@ def hash(self, key): if type_disk_key is sqlite3.Binary: return zlib.adler32(disk_key) & mask elif type_disk_key is str: - return zlib.adler32(disk_key.encode('utf-8')) & mask # pylint: disable=no-member + return zlib.adler32(disk_key.encode('utf-8')) & mask # noqa elif type_disk_key is int: return disk_key % mask else: @@ -1056,7 +1059,9 @@ def incr(self, key, delta=1, default=0, retry=False): raise KeyError(key) value = default + delta - columns = (None, None) + self._disk.store(value, False, key=key) + columns = ( + (None, None) + self._disk.store(value, False, key=key) + ) self._row_insert(db_key, raw, now, columns) self._cull(now, sql, cleanup) return value @@ -1068,7 +1073,9 @@ def incr(self, key, delta=1, default=0, retry=False): raise KeyError(key) value = default + delta - columns = (None, None) + self._disk.store(value, False, key=key) + columns = ( + (None, None) + self._disk.store(value, False, key=key) + ) self._row_update(rowid, now, columns) self._cull(now, sql, cleanup) cleanup(filename) @@ -1184,7 +1191,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, return default (rowid, db_expire_time, db_tag, - mode, filename, db_value), = rows + mode, filename, db_value), = rows # noqa: E127 try: value = self._disk.fetch(mode, filename, db_value, read) @@ -1269,7 +1276,7 @@ def __contains__(self, key): return bool(rows) - def pop(self, key, default=None, expire_time=False, tag=False, retry=False): + def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # noqa: E501 """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. @@ -2341,7 +2348,8 @@ def close(self): def __enter__(self): # Create connection in thread. - connection = self._con # pylint: disable=unused-variable + # pylint: disable=unused-variable + connection = self._con # noqa return self diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 1a59aa2..a579a17 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -5,7 +5,6 @@ import operator import os.path as op import sqlite3 -import sys import tempfile import time @@ -290,7 +289,7 @@ def __contains__(self, key): return key in shard - def pop(self, key, default=None, expire_time=False, tag=False, retry=False): + def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # noqa: E501 """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 5047df5..2e40074 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -197,8 +197,9 @@ def __setitem__(self, index, value): :raises IndexError: if index out of range """ - set_value = lambda key: self._cache.__setitem__(key, value) - self._index(index, set_value) + def _set_value(key): + return self._cache.__setitem__(key, value) + self._index(index, _set_value) def __delitem__(self, index): diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 010f1f2..1a5890d 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -6,7 +6,6 @@ import math import os import random -import sys import threading import time @@ -82,7 +81,11 @@ def acquire(self): "Acquire lock using spin-lock algorithm." while True: added = self._cache.add( - self._key, None, expire=self._expire, tag=self._tag, retry=True, + self._key, + None, + expire=self._expire, + tag=self._tag, + retry=True, ) if added: break diff --git a/tox.ini b/tox.ini index 8e9c2e0..b54e574 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps= pytest-django pytest-xdist commands=python -m pytest -setenv = +setenv= DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH={toxinidir} @@ -27,8 +27,17 @@ testpaths=docs diskcache tests [testenv:pylint] deps= django==2.2.* + flake8 pylint -commands=pylint diskcache +commands= + flake8 diskcache + pylint diskcache [doc8] ignore=D000 + +[flake8] +ignore= + E124 + E303 + W503 From 4f2ff540155c0182ff07a5a3408aceb8c10cff1e Mon Sep 17 00:00:00 2001 From: ume Date: Sat, 1 Aug 2020 10:26:23 +0900 Subject: [PATCH 211/329] Add locked method to Lock --- diskcache/recipes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 1a5890d..85509c7 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -95,6 +95,10 @@ def release(self): "Release lock by deleting key." self._cache.delete(self._key, retry=True) + def locked(self): + "Return true if the lock is acquired." + return self._key in self._cache + def __enter__(self): self.acquire() From a8a014cb96ed0d96c12d07b6b7d4729b54c0c011 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 23 Aug 2020 15:47:49 -0700 Subject: [PATCH 212/329] Add paragraph to caveats about cache volume for Issue #140 --- docs/tutorial.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index b29322c..8f8dc0f 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -892,6 +892,12 @@ thread-pool executor asynchronously. For example:: asyncio.run(set_async('test-key', 'test-value')) +The cache :meth:`volume ` is based on the size of the +database that stores metadata and the size of the values stored in files. It +does not account the size of directories themselves or other filesystem +metadata. If directory count or size is a concern then consider implementing an +alternative :class:`Disk `. + .. _`hash protocol`: https://docs.python.org/library/functions.html#hash .. _`not recommended`: https://www.sqlite.org/faq.html#q5 .. _`performs poorly`: https://www.pythonanywhere.com/forums/topic/1847/ From b1ab12c7a85bf5473fdc491d2d03c9adc6de5bba Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 23 Aug 2020 15:56:19 -0700 Subject: [PATCH 213/329] Bump version to 5.0.0 --- diskcache/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index a301f26..62766c5 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -46,8 +46,8 @@ pass __title__ = 'diskcache' -__version__ = '4.1.0' -__build__ = 0x040100 +__version__ = '5.0.0' +__build__ = 0x050000 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' -__copyright__ = 'Copyright 2016-2018 Grant Jenks' +__copyright__ = 'Copyright 2016-2020 Grant Jenks' From 38bd9019e1b8d5adec52f7bc893a6f662c49cd20 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 23 Aug 2020 16:23:02 -0700 Subject: [PATCH 214/329] Remove Python 2 mapping methods keys/values/items/iter*/view* --- diskcache/persistent.py | 207 ++++++---------------------------------- 1 file changed, 30 insertions(+), 177 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 2e40074..4324660 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1031,196 +1031,49 @@ def __len__(self): return len(self._cache) - if sys.hexversion < 0x03000000: - def keys(self): - """List of index keys. + def keys(self): + """Set-like object providing a view of index keys. - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> index.keys() - ['a', 'b', 'c'] - - :return: list of keys - - """ - return list(self._cache) - - - def values(self): - """List of index values. - - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> index.values() - [1, 2, 3] - - :return: list of values - - """ - return list(self.itervalues()) - - - def items(self): - """List of index items. - - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> index.items() - [('a', 1), ('b', 2), ('c', 3)] - - :return: list of items - - """ - return list(self.iteritems()) - - - def iterkeys(self): - """Iterator of index keys. - - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> list(index.iterkeys()) - ['a', 'b', 'c'] - - :return: iterator of keys - - """ - return iter(self._cache) - - - def itervalues(self): - """Iterator of index values. - - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> list(index.itervalues()) - [1, 2, 3] - - :return: iterator of values - - """ - _cache = self._cache - - for key in _cache: - while True: - try: - yield _cache[key] - except KeyError: - pass - break - - - def iteritems(self): - """Iterator of index items. - - >>> index = Index() - >>> index.update([('a', 1), ('b', 2), ('c', 3)]) - >>> list(index.iteritems()) - [('a', 1), ('b', 2), ('c', 3)] - - :return: iterator of items - - """ - _cache = self._cache - - for key in _cache: - while True: - try: - yield key, _cache[key] - except KeyError: - pass - break - - - def viewkeys(self): - """Set-like object providing a view of index keys. - - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> keys_view = index.viewkeys() - >>> 'b' in keys_view - True - - :return: keys view - - """ - return KeysView(self) - - - def viewvalues(self): - """Set-like object providing a view of index values. - - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> values_view = index.viewvalues() - >>> 2 in values_view - True - - :return: values view - - """ - return ValuesView(self) - - - def viewitems(self): - """Set-like object providing a view of index items. - - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> items_view = index.viewitems() - >>> ('b', 2) in items_view - True - - :return: items view - - """ - return ItemsView(self) - - - else: - def keys(self): - """Set-like object providing a view of index keys. - - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> keys_view = index.keys() - >>> 'b' in keys_view - True + >>> index = Index() + >>> index.update({'a': 1, 'b': 2, 'c': 3}) + >>> keys_view = index.keys() + >>> 'b' in keys_view + True - :return: keys view + :return: keys view - """ - return KeysView(self) + """ + return KeysView(self) - def values(self): - """Set-like object providing a view of index values. + def values(self): + """Set-like object providing a view of index values. - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> values_view = index.values() - >>> 2 in values_view - True + >>> index = Index() + >>> index.update({'a': 1, 'b': 2, 'c': 3}) + >>> values_view = index.values() + >>> 2 in values_view + True - :return: values view + :return: values view - """ - return ValuesView(self) + """ + return ValuesView(self) - def items(self): - """Set-like object providing a view of index items. + def items(self): + """Set-like object providing a view of index items. - >>> index = Index() - >>> index.update({'a': 1, 'b': 2, 'c': 3}) - >>> items_view = index.items() - >>> ('b', 2) in items_view - True + >>> index = Index() + >>> index.update({'a': 1, 'b': 2, 'c': 3}) + >>> items_view = index.items() + >>> ('b', 2) in items_view + True - :return: items view + :return: items view - """ - return ItemsView(self) + """ + return ItemsView(self) __hash__ = None From a1bfadd2c72304cea6aa324a0b40b07b35568ed8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 23 Aug 2020 16:23:36 -0700 Subject: [PATCH 215/329] Update tests for Python 3 --- tests/benchmark_core.py | 9 +-------- tests/benchmark_djangocache.py | 9 +-------- tests/benchmark_glob.py | 2 -- tests/benchmark_incr.py | 2 -- tests/stress_test_core.py | 17 +++-------------- tests/stress_test_deque.py | 2 -- tests/stress_test_deque_mp.py | 2 -- tests/stress_test_fanout.py | 17 +++-------------- tests/stress_test_index.py | 2 -- tests/stress_test_index_mp.py | 2 -- tests/test_core.py | 6 +----- tests/test_fanout.py | 5 ----- tests/utils.py | 2 -- 13 files changed, 9 insertions(+), 68 deletions(-) diff --git a/tests/benchmark_core.py b/tests/benchmark_core.py index 1811de0..2eeae3b 100644 --- a/tests/benchmark_core.py +++ b/tests/benchmark_core.py @@ -6,23 +6,16 @@ """ -from __future__ import print_function - import collections as co import multiprocessing as mp import os +import pickle import random import shutil import sys import time import warnings -if sys.hexversion < 0x03000000: - range = xrange - import cPickle as pickle -else: - import pickle - from utils import display PROCS = 8 diff --git a/tests/benchmark_djangocache.py b/tests/benchmark_djangocache.py index 898188f..0512752 100644 --- a/tests/benchmark_djangocache.py +++ b/tests/benchmark_djangocache.py @@ -6,23 +6,16 @@ """ -from __future__ import print_function - import collections as co import multiprocessing as mp import os +import pickle import random import shutil import sys import time import warnings -if sys.hexversion < 0x03000000: - range = xrange - import cPickle as pickle -else: - import pickle - from utils import display PROCS = 8 diff --git a/tests/benchmark_glob.py b/tests/benchmark_glob.py index 0402ef8..82237de 100644 --- a/tests/benchmark_glob.py +++ b/tests/benchmark_glob.py @@ -1,7 +1,5 @@ "Benchmark glob.glob1 as used by django.core.cache.backends.filebased." -from __future__ import print_function - import os import os.path as op import shutil diff --git a/tests/benchmark_incr.py b/tests/benchmark_incr.py index 4a01628..9c8e2fa 100644 --- a/tests/benchmark_incr.py +++ b/tests/benchmark_incr.py @@ -2,8 +2,6 @@ """ -from __future__ import print_function - import json import multiprocessing as mp import shutil diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index bce0d16..68f2ebd 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -1,11 +1,11 @@ "Stress test diskcache.core.Cache." -from __future__ import print_function - import collections as co from diskcache import Cache, UnknownFileWarning, EmptyDirWarning, Timeout import multiprocessing as mp import os +import pickle +import queue import random import shutil import sys @@ -13,17 +13,6 @@ import time import warnings -try: - import Queue -except ImportError: - import queue as Queue - -if sys.hexversion < 0x03000000: - range = xrange - import cPickle as pickle -else: - import pickle - from .utils import display OPERATIONS = int(1e4) @@ -163,7 +152,7 @@ def dispatch(num, eviction_policy, processes, threads): with open('input-%s.pkl' % num, 'rb') as reader: process_queue = pickle.load(reader) - thread_queues = [Queue.Queue() for _ in range(threads)] + thread_queues = [queue.Queue() for _ in range(threads)] subthreads = [ threading.Thread( target=worker, args=(thread_queue, eviction_policy, processes, threads) diff --git a/tests/stress_test_deque.py b/tests/stress_test_deque.py index cf48812..7b3ac2f 100644 --- a/tests/stress_test_deque.py +++ b/tests/stress_test_deque.py @@ -1,7 +1,5 @@ """Stress test diskcache.persistent.Deque.""" -from __future__ import print_function - import collections as co import functools as ft import itertools as it diff --git a/tests/stress_test_deque_mp.py b/tests/stress_test_deque_mp.py index 4624d71..db7b5c4 100644 --- a/tests/stress_test_deque_mp.py +++ b/tests/stress_test_deque_mp.py @@ -1,7 +1,5 @@ """Stress test diskcache.persistent.Deque.""" -from __future__ import print_function - import functools as ft import itertools as it import multiprocessing as mp diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py index 080b8d8..422e874 100644 --- a/tests/stress_test_fanout.py +++ b/tests/stress_test_fanout.py @@ -1,11 +1,11 @@ "Stress test diskcache.core.Cache." -from __future__ import print_function - import collections as co from diskcache import FanoutCache, UnknownFileWarning, EmptyDirWarning import multiprocessing as mp import os +import pickle +import queue import random import shutil import sys @@ -13,17 +13,6 @@ import time import warnings -try: - import Queue -except ImportError: - import queue as Queue - -if sys.hexversion < 0x03000000: - range = xrange - import cPickle as pickle -else: - import pickle - from .utils import display OPERATIONS = int(1e4) @@ -155,7 +144,7 @@ def dispatch(num, eviction_policy, processes, threads): with open('input-%s.pkl' % num, 'rb') as reader: process_queue = pickle.load(reader) - thread_queues = [Queue.Queue() for _ in range(threads)] + thread_queues = [queue.Queue() for _ in range(threads)] subthreads = [ threading.Thread( target=worker, args=(thread_queue, eviction_policy, processes, threads) diff --git a/tests/stress_test_index.py b/tests/stress_test_index.py index 2846d9c..e7ba3f6 100644 --- a/tests/stress_test_index.py +++ b/tests/stress_test_index.py @@ -1,7 +1,5 @@ """Stress test diskcache.persistent.Index.""" -from __future__ import print_function - import collections as co import itertools as it import random diff --git a/tests/stress_test_index_mp.py b/tests/stress_test_index_mp.py index b3ed813..f8718f0 100644 --- a/tests/stress_test_index_mp.py +++ b/tests/stress_test_index_mp.py @@ -1,7 +1,5 @@ """Stress test diskcache.persistent.Index.""" -from __future__ import print_function - import itertools as it import multiprocessing as mp import os diff --git a/tests/test_core.py b/tests/test_core.py index 36311e5..31b4034 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -22,14 +22,10 @@ from unittest import mock -import diskcache import diskcache as dc pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning) -if sys.hexversion < 0x03000000: - range = xrange - @pytest.fixture def cache(): with dc.Cache() as cache: @@ -100,7 +96,7 @@ def test_custom_disk(): shutil.rmtree(cache.directory, ignore_errors=True) -class SHA256FilenameDisk(diskcache.Disk): +class SHA256FilenameDisk(dc.Disk): def filename(self, key=dc.UNKNOWN, value=dc.UNKNOWN): filename = hashlib.sha256(key).hexdigest()[:32] full_path = op.join(self._directory, filename) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 390961b..aa3c833 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -1,7 +1,5 @@ "Test diskcache.fanout.FanoutCache." -from __future__ import print_function - import collections as co import errno import functools as ft @@ -28,9 +26,6 @@ warnings.simplefilter('error') warnings.simplefilter('ignore', category=dc.EmptyDirWarning) -if sys.hexversion < 0x03000000: - range = xrange - @pytest.fixture def cache(): diff --git a/tests/utils.py b/tests/utils.py index f2370da..47da791 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import os import subprocess as sp From cdeee61aa15335fd95ef923f6807cc451d05209b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 23 Aug 2020 16:30:49 -0700 Subject: [PATCH 216/329] Bump version to 5.0.1 --- diskcache/__init__.py | 4 ++-- diskcache/persistent.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 62766c5..e1eb93f 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -46,8 +46,8 @@ pass __title__ = 'diskcache' -__version__ = '5.0.0' -__build__ = 0x050000 +__version__ = '5.0.1' +__build__ = 0x050001 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2020 Grant Jenks' diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 4324660..d0a452e 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -3,7 +3,6 @@ """ import operator as op -import sys from collections import OrderedDict from collections.abc import MutableMapping, Sequence From 1f339c12fec3baf6955cd6e1596a16bb6bba447b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 25 Aug 2020 21:54:32 -0700 Subject: [PATCH 217/329] Bump version to 5.0.2 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index e1eb93f..01efa1b 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -46,8 +46,8 @@ pass __title__ = 'diskcache' -__version__ = '5.0.1' -__build__ = 0x050001 +__version__ = '5.0.2' +__build__ = 0x050002 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2020 Grant Jenks' From d6c3a4950beeca124575a5b6d039af381ec00ba0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 8 Sep 2020 21:45:45 -0700 Subject: [PATCH 218/329] Add python_requires kwarg to setup --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d9be963..f8c465e 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ def run_tests(self): packages=['diskcache'], tests_require=['tox'], cmdclass={'test': Tox}, + python_requires='>=3', install_requires=[], classifiers=( 'Development Status :: 5 - Production/Stable', From 9670fbb957af8caac826ddc1144da913682de447 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 8 Sep 2020 21:46:12 -0700 Subject: [PATCH 219/329] Bump version to 5.0.3 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 01efa1b..a98cfee 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -46,8 +46,8 @@ pass __title__ = 'diskcache' -__version__ = '5.0.2' -__build__ = 0x050002 +__version__ = '5.0.3' +__build__ = 0x050003 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2020 Grant Jenks' From 49205f5fc25bb0886e9dcc764ec2dcc3d8afe36d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 8 Nov 2020 19:25:54 -0800 Subject: [PATCH 220/329] Prevent cache shard attribute access when unsafe --- diskcache/fanout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index a579a17..3b8ae30 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -58,6 +58,9 @@ def directory(self): def __getattr__(self, name): + safe_names = {'timeout', 'disk'} + valid_name = name in DEFAULT_SETTINGS or name in safe_names + assert valid_name, f'cannot access {name} in cache shard' return getattr(self._shards[0], name) From 77ca254ae2fda78550ffd678c7666d62ea0c0af1 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 8 Nov 2020 19:26:11 -0800 Subject: [PATCH 221/329] Support transactions in FanoutCache (probably a bad idea) --- diskcache/fanout.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 3b8ae30..7b85df5 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -1,5 +1,6 @@ "Fanout cache automatically shards keys and values." +import contextlib as cl import functools import itertools as it import operator @@ -64,6 +65,40 @@ def __getattr__(self, name): return getattr(self._shards[0], name) + @cl.contextmanager + def transact(self, retry=True): + """Context manager to perform a transaction by locking the cache. + + While the cache is locked, no other write operation is permitted. + Transactions should therefore be as short as possible. Read and write + operations performed in a transaction are atomic. Read operations may + occur concurrent to a transaction. + + Transactions may be nested and may not be shared between threads. + + Blocks until transactions are held on all cache shards by retrying as + necessary. + + >>> cache = FanoutCache() + >>> with cache.transact(): # Atomically increment two keys. + ... _ = cache.incr('total', 123.4) + ... _ = cache.incr('count', 1) + >>> with cache.transact(): # Atomically calculate average. + ... average = cache['total'] / cache['count'] + >>> average + 123.4 + + :return: context manager for use in `with` statement + + """ + assert retry, 'retry must be True in FanoutCache' + with cl.ExitStack() as stack: + for shard in self._shards: + shard_transaction = shard.transact(retry=True) + stack.enter_context(shard_transaction) + yield + + def set(self, key, value, expire=None, read=False, tag=None, retry=False): """Set `key` and `value` item in cache. From c5259bd6b6046789ae2bcdf1955e1781adb07bd4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 8 Nov 2020 19:27:27 -0800 Subject: [PATCH 222/329] Bump version to 5.1.0 --- diskcache/__init__.py | 4 ++-- diskcache/fanout.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index a98cfee..e4d747b 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -46,8 +46,8 @@ pass __title__ = 'diskcache' -__version__ = '5.0.3' -__build__ = 0x050003 +__version__ = '5.1.0' +__build__ = 0x050100 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2020 Grant Jenks' diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 7b85df5..7e227c8 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -61,7 +61,7 @@ def directory(self): def __getattr__(self, name): safe_names = {'timeout', 'disk'} valid_name = name in DEFAULT_SETTINGS or name in safe_names - assert valid_name, f'cannot access {name} in cache shard' + assert valid_name, 'cannot access {} in cache shard'.format(name) return getattr(self._shards[0], name) From 40ce0dedb90ccefacaea41ac11c19ddd491d5a6d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 8 Nov 2020 20:24:02 -0800 Subject: [PATCH 223/329] Use no hardcoded /tmp/diskcache/... paths in tests --- tests/test_deque.py | 5 +++-- tests/test_index.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_deque.py b/tests/test_deque.py index ddf2338..1ca966d 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -4,6 +4,7 @@ import pickle import pytest import shutil +import tempfile from unittest import mock @@ -26,7 +27,7 @@ def deque(): def test_init(): - directory = '/tmp/diskcache/deque' + directory = tempfile.mkdtemp() sequence = list('abcde') deque = dc.Deque(sequence, None) @@ -156,7 +157,7 @@ def test_indexerror(deque): def test_repr(): - directory = '/tmp/diskcache/deque' + directory = tempfile.mkdtemp() deque = dc.Deque(directory=directory) assert repr(deque) == 'Deque(directory=%r)' % directory diff --git a/tests/test_index.py b/tests/test_index.py index ef2a0d0..575558c 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -5,6 +5,7 @@ import pytest import shutil import sys +import tempfile from unittest import mock @@ -26,7 +27,7 @@ def index(): def test_init(): - directory = '/tmp/diskcache/index' + directory = tempfile.mkdtemp() mapping = {'a': 5, 'b': 4, 'c': 3, 'd': 2, 'e': 1} index = dc.Index(None, mapping) From c4ba1f78bb8494bcf6aba9d7d1c3aa49a1093508 Mon Sep 17 00:00:00 2001 From: Cologler Date: Sat, 12 Dec 2020 00:36:01 +0800 Subject: [PATCH 224/329] replace open mode 'w' to 'x' --- diskcache/core.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 4317160..1fae11b 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -207,14 +207,14 @@ def store(self, value, read, key=UNKNOWN): else: filename, full_path = self.filename(key, value) - with open(full_path, 'wb') as writer: + with open(full_path, 'xb') as writer: writer.write(value) return len(value), MODE_BINARY, filename, None elif type_value is str: filename, full_path = self.filename(key, value) - with open(full_path, 'w', encoding='UTF-8') as writer: + with open(full_path, 'x', encoding='UTF-8') as writer: writer.write(value) size = op.getsize(full_path) @@ -224,7 +224,7 @@ def store(self, value, read, key=UNKNOWN): reader = ft.partial(value.read, 2 ** 22) filename, full_path = self.filename(key, value) - with open(full_path, 'wb') as writer: + with open(full_path, 'xb') as writer: for chunk in iter(reader, b''): size += len(chunk) writer.write(chunk) @@ -238,7 +238,7 @@ def store(self, value, read, key=UNKNOWN): else: filename, full_path = self.filename(key, value) - with open(full_path, 'wb') as writer: + with open(full_path, 'xb') as writer: writer.write(result) return len(result), MODE_PICKLE, filename, None From ce44a42cfe01ac48e167efeff2612a6e768fdf55 Mon Sep 17 00:00:00 2001 From: C2D <50617709+i404788@users.noreply.github.com> Date: Wed, 20 Jan 2021 16:34:02 +0000 Subject: [PATCH 225/329] Use disk provided by the user whenever possible --- diskcache/fanout.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 7e227c8..b39c744 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -36,6 +36,7 @@ def __init__(self, directory=None, shards=8, timeout=0.010, disk=Disk, self._count = shards self._directory = directory + self._disk = disk self._shards = tuple( Cache( directory=op.join(directory, '%03d' % num), @@ -622,7 +623,7 @@ def cache(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'cache', *parts) - temp = Cache(directory=directory) + temp = Cache(directory=directory, disk=self._disk) _caches[name] = temp return temp From 9a300bd4ab4896cfc4eb90703d155d84924be447 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Jan 2021 20:27:27 -0800 Subject: [PATCH 226/329] Remove transaction from Deque.__init__ When initializing a Deque, a transaction was used to extend elements from the given iterable. The transaction is not used in Index.__init__ or in the FanoutCache.fromcache API. Users that want Deque.__init__ to use a transaction as before should use: d = Deque() with d.transact(): d.extend(iterable) The transaction is therefore explicit and consistent with other APIs. --- diskcache/persistent.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index d0a452e..6fc072b 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -81,8 +81,7 @@ def __init__(self, iterable=(), directory=None): """ self._cache = Cache(directory, eviction_policy='none') - with self.transact(): - self.extend(iterable) + self.extend(iterable) @classmethod From 8b0f68b8c208ddd5a6c06e5c6a9d55ef37f5fb1a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 20 Jan 2021 20:37:55 -0800 Subject: [PATCH 227/329] Use the same Disk in FanoutCache as in Index and Deque subdirs --- diskcache/fanout.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index b39c744..9870c06 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -652,9 +652,10 @@ def deque(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'deque', *parts) - temp = Deque(directory=directory) - _deques[name] = temp - return temp + cache = Cache(directory=directory, disk=self._disk) + deque = Deque.fromcache(cache) + _deques[name] = deque + return deque def index(self, name): @@ -684,9 +685,10 @@ def index(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'index', *parts) - temp = Index(directory) - _indexes[name] = temp - return temp + cache = Cache(directory=directory, disk=self._disk) + index = Index.fromcache(cache) + _indexes[name] = index + return index FanoutCache.memoize = Cache.memoize From fcd31646cf81b6762d1d31cffafb8fd97510f024 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 10:29:07 -0800 Subject: [PATCH 228/329] Remove travis and appveyor in favor of GitHub Actions --- .github/workflows/integration.yml | 52 ++++++++++++++++++ .github/workflows/release.yml | 38 +++++++++++++ .travis.yml | 19 ------- appveyor.yml | 22 -------- tox.ini | 88 ++++++++++++++++++++++++------- 5 files changed, 159 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .travis.yml delete mode 100644 appveyor.yml diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..7dfc198 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,52 @@ +name: integration + +on: [push] + +jobs: + + checks: + runs-on: ubuntu-latest + strategy: + max-parallel: 8 + matrix: + check: [bluecheck, doc8, docs, flake8, isortcheck, mypy, pylint, rstcheck] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + pip install --upgrade pip + pip install tox + - name: Run checks with tox + run: | + tox -e ${{ matrix.check }} + + tests: + needs: checks + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 8 + matrix: + os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-16.04] + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + - name: Set up Python ${{ matrix.python-version }} x64 + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + + - uses: actions/checkout@v2 + + - name: Install tox + run: | + pip install --upgrade pip + pip install tox + + - name: Test with tox + run: tox -e py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..57c68e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: release + +on: + push: + tags: + - v* + +jobs: + + upload: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: Create source dist + run: python setup.py sdist + + - name: Create wheel dist + run: python setup.py bdist_wheel + + - name: Upload with twine + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + run: | + ls -l dist/* + twine upload dist/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5e5c760..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -sudo: false -language: python -install: python -m pip install tox -script: python -m tox -e py -matrix: - include: - - python: 3.5 - env: TOXENV=py35 - - python: 3.6 - env: TOXENV=py36 - - python: 3.7 - dist: xenial - env: TOXENV=py37 - - python: 3.8 - dist: xenial - env: TOXENV=py38 - - python: 3.8 - dist: xenial - env: TOXENV=pylint diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 1f52a08..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,22 +0,0 @@ -environment: - - matrix: - - - PYTHON: "C:\\Python35" - - PYTHON: "C:\\Python36" - - PYTHON: "C:\\Python37" - - PYTHON: "C:\\Python38" - - PYTHON: "C:\\Python35-x64" - - PYTHON: "C:\\Python36-x64" - - PYTHON: "C:\\Python37-x64" - - PYTHON: "C:\\Python38-x64" - -install: - - - "%PYTHON%\\python.exe -m pip install tox" - -build: off - -test_script: - - - "%PYTHON%\\python.exe -m tox -e py" diff --git a/tox.ini b/tox.ini index b54e574..6bbe69c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,43 +1,93 @@ [tox] -envlist=py35,py36,py37,py38,pylint +envlist=bluecheck,doc8,docs,isortcheck,flake8,mypy,pylint,rstcheck,py36,py37,py38,py39 skip_missing_interpreters=True [testenv] +commands=pytest deps= django==2.2.* pytest pytest-django pytest-xdist -commands=python -m pytest setenv= DJANGO_SETTINGS_MODULE=tests.settings - PYTHONPATH={toxinidir} + +[testenv:blue] +commands=blue {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests +deps=blue + +[testenv:bluecheck] +commands=blue --check {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests +deps=blue + +[testenv:doc8] +deps=doc8 +commands=doc8 docs + +[testenv:docs] +allowlist_externals=make +changedir=docs +commands=make html +deps=sphinx + +[testenv:flake8] +commands=flake8 {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests +deps=flake8 + +[testenv:isort] +commands=isort {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests +deps=isort + +[testenv:isortcheck] +commands=isort --check {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests +deps=isort + +[testenv:mypy] +commands=mypy {toxinidir}/diskcache +deps=mypy + +[testenv:pylint] +commands=pylint {toxinidir}/diskcache +deps=pylint + +[testenv:rstcheck] +commands=rstcheck {toxinidir}/README.rst +deps=rstcheck + +[testenv:uploaddocs] +allowlist_externals=rsync +changedir=docs +commands= + rsync -azP --stats --delete _build/html/ \ + grantjenks.com:/srv/www/www.grantjenks.com/public/docs/diskcache/ + +[isort] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 79 [pytest] addopts= -n auto + --cov-branch + --cov-fail-under=100 + --cov-report=term-missing + --cov=diskcache + --doctest-glob="*.rst" --ignore tests/benchmark_core.py --ignore tests/benchmark_djangocache.py --ignore tests/benchmark_glob.py --ignore tests/issue_85.py --ignore tests/plot.py -norecursedirs=site-packages -testpaths=docs diskcache tests - -[testenv:pylint] -deps= - django==2.2.* - flake8 - pylint -commands= - flake8 diskcache - pylint diskcache [doc8] -ignore=D000 +# ignore=D000 [flake8] -ignore= - E124 - E303 - W503 +# ignore= +# E124 +# E303 +# W503 From 8d2009abd1e92875a31b21bdf2799ea757fb6b24 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:19:45 -0800 Subject: [PATCH 229/329] Rewrite k/v store benchmarking to avoid IPython "magic" syntax --- tests/benchmark_kv_store.py | 110 +++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/tests/benchmark_kv_store.py b/tests/benchmark_kv_store.py index d1459e7..e214f1a 100644 --- a/tests/benchmark_kv_store.py +++ b/tests/benchmark_kv_store.py @@ -1,48 +1,78 @@ -import dbm +"""Benchmarking Key-Value Stores + +$ python -m IPython tests/benchmark_kv_store.py + +""" + import diskcache -import pickledb -import shelve -import sqlitedict -import timeit + +from IPython import get_ipython + +ipython = get_ipython() +assert ipython is not None, 'No IPython! Run with $ ipython ...' value = 'value' print('diskcache set') dc = diskcache.FanoutCache('/tmp/diskcache') -%timeit -n 100 -r 7 dc['key'] = value +ipython.magic("timeit -n 100 -r 7 dc['key'] = value") print('diskcache get') -%timeit -n 100 -r 7 dc['key'] +ipython.magic("timeit -n 100 -r 7 dc['key']") print('diskcache set/delete') -%timeit -n 100 -r 7 dc['key'] = value; del dc['key'] - -print('dbm set') -d = dbm.open('/tmp/dbm', 'c') -%timeit -n 100 -r 7 d['key'] = value; d.sync() -print('dbm get') -%timeit -n 100 -r 7 d['key'] -print('dbm set/delete') -%timeit -n 100 -r 7 d['key'] = value; del d['key']; d.sync() - -print('shelve set') -s = shelve.open('/tmp/shelve') -%timeit -n 100 -r 7 s['key'] = value; s.sync() -print('shelve get') -%timeit -n 100 -r 7 s['key'] -print('shelve set/delete') -%timeit -n 100 -r 7 s['key'] = value; del s['key']; s.sync() - -print('sqlitedict set') -sd = sqlitedict.SqliteDict('/tmp/sqlitedict', autocommit=True) -%timeit -n 100 -r 7 sd['key'] = value -print('sqlitedict get') -%timeit -n 100 -r 7 sd['key'] -print('sqlitedict set/delete') -%timeit -n 100 -r 7 sd['key'] = value; del sd['key'] - -print('pickledb set') -p = pickledb.load('/tmp/pickledb', True) -%timeit -n 100 -r 7 p['key'] = value -print('pickledb get') -%timeit -n 100 -r 7 p = pickledb.load('/tmp/pickledb', True); p['key'] -print('pickledb set/delete') -%timeit -n 100 -r 7 p['key'] = value; del p['key'] +ipython.magic("timeit -n 100 -r 7 dc['key'] = value; del dc['key']") + +try: + import dbm.gnu # Only trust GNU DBM +except ImportError: + print('Error: Cannot import dbm.gnu') + print('Error: Skipping import shelve') +else: + print('dbm set') + d = dbm.gnu.open('/tmp/dbm', 'c') + ipython.magic("timeit -n 100 -r 7 d['key'] = value; d.sync()") + print('dbm get') + ipython.magic("timeit -n 100 -r 7 d['key']") + print('dbm set/delete') + ipython.magic( + "timeit -n 100 -r 7 d['key'] = value; d.sync(); del d['key']; d.sync()" + ) + + import shelve + + print('shelve set') + s = shelve.open('/tmp/shelve') + ipython.magic("timeit -n 100 -r 7 s['key'] = value; s.sync()") + print('shelve get') + ipython.magic("timeit -n 100 -r 7 s['key']") + print('shelve set/delete') + ipython.magic( + "timeit -n 100 -r 7 s['key'] = value; s.sync(); del s['key']; s.sync()" + ) + +try: + import sqlitedict +except ImportError: + print('Error: Cannot import sqlitedict') +else: + print('sqlitedict set') + sd = sqlitedict.SqliteDict('/tmp/sqlitedict', autocommit=True) + ipython.magic("timeit -n 100 -r 7 sd['key'] = value") + print('sqlitedict get') + ipython.magic("timeit -n 100 -r 7 sd['key']") + print('sqlitedict set/delete') + ipython.magic("timeit -n 100 -r 7 sd['key'] = value; del sd['key']") + +try: + import pickledb +except ImportError: + print('Error: Cannot import pickledb') +else: + print('pickledb set') + p = pickledb.load('/tmp/pickledb', True) + ipython.magic("timeit -n 100 -r 7 p['key'] = value") + print('pickledb get') + ipython.magic( + "timeit -n 100 -r 7 p = pickledb.load('/tmp/pickledb', True); p['key']" + ) + print('pickledb set/delete') + ipython.magic("timeit -n 100 -r 7 p['key'] = value; del p['key']") From b7ecbe9e9d9d51d78b49058c6bea68400ff1ecf5 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:23:00 -0800 Subject: [PATCH 230/329] Update development requirements for editable install --- requirements.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 835f48a..a9023a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,12 @@ +-e . +blue coverage django==2.2.* django_redis doc8 -gj +flake8 +ipython +pickleDB pylibmc pylint pytest @@ -12,6 +16,7 @@ pytest-env pytest-xdist rstcheck sphinx +sqlitedict tox twine wheel From 2ed7787817d1a7587dba077e972faea16fb6f5d8 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:25:53 -0800 Subject: [PATCH 231/329] I blue it --- diskcache/__init__.py | 8 +- diskcache/core.py | 323 +++++++++++++++++---------------- diskcache/djangocache.py | 89 +++++---- diskcache/fanout.py | 59 ++---- diskcache/persistent.py | 62 +------ diskcache/recipes.py | 63 +++++-- setup.py | 2 + tests/benchmark_core.py | 90 ++++++--- tests/benchmark_djangocache.py | 23 ++- tests/benchmark_glob.py | 6 +- tests/issue_109.py | 3 +- tests/issue_85.py | 1 + tests/models.py | 4 +- tests/plot.py | 23 ++- tests/plot_early_recompute.py | 8 + tests/settings_benchmark.py | 6 +- tests/stress_test_core.py | 116 +++++++++--- tests/stress_test_fanout.py | 116 +++++++++--- tests/test_core.py | 12 +- tests/test_djangocache.py | 245 ++++++++++++++++--------- tests/test_recipes.py | 6 + tests/utils.py | 23 ++- 22 files changed, 787 insertions(+), 501 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index e4d747b..0300123 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -7,7 +7,12 @@ """ from .core import ( - Cache, Disk, EmptyDirWarning, JSONDisk, UnknownFileWarning, Timeout + Cache, + Disk, + EmptyDirWarning, + JSONDisk, + UnknownFileWarning, + Timeout, ) from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache @@ -40,6 +45,7 @@ try: from .djangocache import DjangoCache # noqa + __all__.append('DjangoCache') except Exception: # pylint: disable=broad-except # Django not installed or not setup so ignore. diff --git a/diskcache/core.py b/diskcache/core.py index 1fae11b..419c64f 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -29,12 +29,14 @@ def full_name(func): try: WindowsError except NameError: + class WindowsError(Exception): "Windows error place-holder on platforms without support." class Constant(tuple): "Pretty display of immutable constant." + def __new__(cls, name): return tuple.__new__(cls, (name,)) @@ -54,15 +56,15 @@ def __repr__(self): DEFAULT_SETTINGS = { u'statistics': 0, # False - u'tag_index': 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_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'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, } @@ -109,6 +111,7 @@ def __repr__(self): class Disk(object): "Cache key and value serialization for SQLite database and files." + def __init__(self, directory, min_file_size=0, pickle_protocol=0): """Initialize disk instance. @@ -121,7 +124,6 @@ def __init__(self, directory, min_file_size=0, pickle_protocol=0): self.min_file_size = min_file_size self.pickle_protocol = pickle_protocol - def hash(self, key): """Compute portable hash for `key`. @@ -143,7 +145,6 @@ def hash(self, key): assert type_disk_key is float return zlib.adler32(struct.pack('!d', disk_key)) & mask - def put(self, key): """Convert `key` to fields key and raw for Cache table. @@ -156,17 +157,20 @@ def put(self, key): if type_key is bytes: return sqlite3.Binary(key), True - elif ((type_key is str) - or (type_key is int - and -9223372036854775808 <= key <= 9223372036854775807) - or (type_key is float)): + elif ( + (type_key is str) + or ( + type_key is int + and -9223372036854775808 <= key <= 9223372036854775807 + ) + or (type_key is float) + ): return key, True else: data = pickle.dumps(key, protocol=self.pickle_protocol) result = pickletools.optimize(data) return sqlite3.Binary(result), False - def get(self, key, raw): """Convert fields `key` and `raw` from Cache table to key. @@ -181,7 +185,6 @@ def get(self, key, raw): else: return pickle.load(io.BytesIO(key)) - def store(self, value, read, key=UNKNOWN): """Convert `value` to fields size, mode, filename, and value for Cache table. @@ -196,10 +199,14 @@ def store(self, value, read, key=UNKNOWN): type_value = type(value) min_file_size = self.min_file_size - if ((type_value is str and len(value) < min_file_size) - or (type_value is int - and -9223372036854775808 <= value <= 9223372036854775807) - or (type_value is float)): + if ( + (type_value is str and len(value) < min_file_size) + or ( + type_value is int + and -9223372036854775808 <= value <= 9223372036854775807 + ) + or (type_value is float) + ): return 0, MODE_RAW, None, value elif type_value is bytes: if len(value) < min_file_size: @@ -243,7 +250,6 @@ def store(self, value, read, key=UNKNOWN): return len(result), MODE_PICKLE, filename, None - def fetch(self, mode, filename, value, read): """Convert fields `mode`, `filename`, and `value` from Cache table to value. @@ -275,7 +281,6 @@ def fetch(self, mode, filename, value, read): else: return pickle.load(io.BytesIO(value)) - def filename(self, key=UNKNOWN, value=UNKNOWN): """Return filename and full-path tuple for file storage. @@ -310,7 +315,6 @@ def filename(self, key=UNKNOWN, value=UNKNOWN): full_path = op.join(self._directory, filename) return filename, full_path - def remove(self, filename): """Remove a file given by `filename`. @@ -335,6 +339,7 @@ def remove(self, filename): class JSONDisk(Disk): "Cache key and value using JSON serialization with zlib compression." + def __init__(self, directory, compress_level=1, **kwargs): """Initialize JSON disk instance. @@ -351,25 +356,21 @@ def __init__(self, directory, compress_level=1, **kwargs): self.compress_level = compress_level super().__init__(directory, **kwargs) - def put(self, key): json_bytes = json.dumps(key).encode('utf-8') data = zlib.compress(json_bytes, self.compress_level) return super().put(data) - def get(self, key, raw): data = super().get(key, raw) return json.loads(zlib.decompress(data).decode('utf-8')) - def store(self, value, read, key=UNKNOWN): if not read: json_bytes = json.dumps(value).encode('utf-8') value = zlib.compress(json_bytes, self.compress_level) return super().store(value, read, key=key) - def fetch(self, mode, filename, value, read): data = super().fetch(mode, filename, value, read) if not read: @@ -419,6 +420,7 @@ def args_to_key(base, args, kwargs, typed): class Cache(object): "Disk and file backed cache." + def __init__(self, directory=None, timeout=60, disk=Disk, **settings): """Initialize cache instance. @@ -451,7 +453,7 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): raise EnvironmentError( error.errno, 'Cache directory "%s" does not exist' - ' and could not be created' % self._directory + ' and could not be created' % self._directory, ) from None sql = self._sql_retry @@ -459,9 +461,9 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): # Setup Settings table. try: - current_settings = dict(sql( - 'SELECT key, value FROM Settings' - ).fetchall()) + current_settings = dict( + sql('SELECT key, value FROM Settings').fetchall() + ) except sqlite3.OperationalError: current_settings = {} @@ -478,7 +480,8 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): if key.startswith('sqlite_'): self.reset(key, value, update=False) - sql('CREATE TABLE IF NOT EXISTS Settings (' + sql( + 'CREATE TABLE IF NOT EXISTS Settings (' ' key TEXT NOT NULL UNIQUE,' ' value)' ) @@ -486,7 +489,8 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): # Setup Disk object (must happen after settings initialized). kwargs = { - key[5:]: value for key, value in sets.items() + key[5:]: value + for key, value in sets.items() if key.startswith('disk_') } self._disk = disk(directory, **kwargs) @@ -503,11 +507,12 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): sql(query, (key, value)) self.reset(key) - (self._page_size,), = sql('PRAGMA page_size').fetchall() + ((self._page_size,),) = sql('PRAGMA page_size').fetchall() # Setup Cache table. - sql('CREATE TABLE IF NOT EXISTS Cache (' + sql( + 'CREATE TABLE IF NOT EXISTS Cache (' ' rowid INTEGER PRIMARY KEY,' ' key BLOB,' ' raw INTEGER,' @@ -522,11 +527,13 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): ' value BLOB)' ) - sql('CREATE UNIQUE INDEX IF NOT EXISTS Cache_key_raw ON' + sql( + 'CREATE UNIQUE INDEX IF NOT EXISTS Cache_key_raw ON' ' Cache(key, raw)' ) - sql('CREATE INDEX IF NOT EXISTS Cache_expire_time ON' + sql( + 'CREATE INDEX IF NOT EXISTS Cache_expire_time ON' ' Cache (expire_time)' ) @@ -537,32 +544,37 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): # Use triggers to keep Metadata updated. - sql('CREATE TRIGGER IF NOT EXISTS Settings_count_insert' + sql( + 'CREATE TRIGGER IF NOT EXISTS Settings_count_insert' ' AFTER INSERT ON Cache FOR EACH ROW BEGIN' ' UPDATE Settings SET value = value + 1' ' WHERE key = "count"; END' ) - sql('CREATE TRIGGER IF NOT EXISTS Settings_count_delete' + sql( + 'CREATE TRIGGER IF NOT EXISTS Settings_count_delete' ' AFTER DELETE ON Cache FOR EACH ROW BEGIN' ' UPDATE Settings SET value = value - 1' ' WHERE key = "count"; END' ) - sql('CREATE TRIGGER IF NOT EXISTS Settings_size_insert' + sql( + 'CREATE TRIGGER IF NOT EXISTS Settings_size_insert' ' AFTER INSERT ON Cache FOR EACH ROW BEGIN' ' UPDATE Settings SET value = value + NEW.size' ' WHERE key = "size"; END' ) - sql('CREATE TRIGGER IF NOT EXISTS Settings_size_update' + sql( + 'CREATE TRIGGER IF NOT EXISTS Settings_size_update' ' AFTER UPDATE ON Cache FOR EACH ROW BEGIN' ' UPDATE Settings' ' SET value = value + NEW.size - OLD.size' ' WHERE key = "size"; END' ) - sql('CREATE TRIGGER IF NOT EXISTS Settings_size_delete' + sql( + 'CREATE TRIGGER IF NOT EXISTS Settings_size_delete' ' AFTER DELETE ON Cache FOR EACH ROW BEGIN' ' UPDATE Settings SET value = value - OLD.size' ' WHERE key = "size"; END' @@ -581,25 +593,21 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): self._timeout = timeout self._sql # pylint: disable=pointless-statement - @property def directory(self): """Cache directory.""" return self._directory - @property def timeout(self): """SQLite connection timeout value in seconds.""" return self._timeout - @property def disk(self): """Disk used for serialization.""" return self._disk - @property def _con(self): # Check process ID to support process forking. If the process @@ -638,12 +646,10 @@ def _con(self): return con - @property def _sql(self): return self._con.execute - @property def _sql_retry(self): sql = self._sql @@ -671,7 +677,6 @@ def _execute_with_retry(statement, *args, **kwargs): return _execute_with_retry - @cl.contextmanager def transact(self, retry=False): """Context manager to perform a transaction by locking the cache. @@ -703,7 +708,6 @@ def transact(self, retry=False): with self._transact(retry=retry): yield - @cl.contextmanager def _transact(self, retry=False, filename=None): sql = self._sql @@ -745,7 +749,6 @@ def _transact(self, retry=False, filename=None): if name is not None: _disk_remove(name) - def set(self, key, value, expire=None, read=False, tag=None, retry=False): """Set `key` and `value` item in cache. @@ -801,7 +804,7 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): ).fetchall() if rows: - (rowid, old_filename), = rows + ((rowid, old_filename),) = rows cleanup(old_filename) self._row_update(rowid, now, columns) else: @@ -811,7 +814,6 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): return True - def __setitem__(self, key, value): """Set corresponding `value` for `key` in cache. @@ -823,11 +825,11 @@ def __setitem__(self, key, value): """ self.set(key, value, retry=True) - def _row_update(self, rowid, now, columns): sql = self._sql expire_time, tag, size, mode, filename, value = columns - sql('UPDATE Cache SET' + sql( + 'UPDATE Cache SET' ' store_time = ?,' ' expire_time = ?,' ' access_time = ?,' @@ -837,11 +839,12 @@ def _row_update(self, rowid, now, columns): ' mode = ?,' ' filename = ?,' ' value = ?' - ' WHERE rowid = ?', ( - now, # store_time + ' WHERE rowid = ?', + ( + now, # store_time expire_time, - now, # access_time - 0, # access_count + now, # access_time + 0, # access_count tag, size, mode, @@ -851,20 +854,21 @@ def _row_update(self, rowid, now, columns): ), ) - def _row_insert(self, key, raw, now, columns): sql = self._sql expire_time, tag, size, mode, filename, value = columns - sql('INSERT INTO Cache(' + sql( + 'INSERT INTO Cache(' ' key, raw, store_time, expire_time, access_time,' ' access_count, tag, size, mode, filename, value' - ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ( + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ( key, raw, - now, # store_time + now, # store_time expire_time, - now, # access_time - 0, # access_count + now, # access_time + 0, # access_count tag, size, mode, @@ -873,7 +877,6 @@ def _row_insert(self, key, raw, now, columns): ), ) - def _cull(self, now, sql, cleanup, limit=None): cull_limit = self.cull_limit if limit is None else limit @@ -892,13 +895,12 @@ def _cull(self, now, sql, cleanup, limit=None): rows = sql(select_expired, (now, cull_limit)).fetchall() if rows: - delete_expired = ( - 'DELETE FROM Cache WHERE rowid IN (%s)' - % (select_expired_template % 'rowid') + delete_expired = 'DELETE FROM Cache WHERE rowid IN (%s)' % ( + select_expired_template % 'rowid' ) sql(delete_expired, (now, cull_limit)) - for filename, in rows: + for (filename,) in rows: cleanup(filename) cull_limit -= len(rows) @@ -917,16 +919,14 @@ def _cull(self, now, sql, cleanup, limit=None): rows = sql(select_filename, (cull_limit,)).fetchall() if rows: - delete = ( - 'DELETE FROM Cache WHERE rowid IN (%s)' - % (select_policy.format(fields='rowid', now=now)) + delete = 'DELETE FROM Cache WHERE rowid IN (%s)' % ( + select_policy.format(fields='rowid', now=now) ) sql(delete, (cull_limit,)) - for filename, in rows: + for (filename,) in rows: cleanup(filename) - def touch(self, key, expire=None, retry=False): """Touch `key` in cache and update `expire` time. @@ -953,17 +953,17 @@ def touch(self, key, expire=None, retry=False): ).fetchall() if rows: - (rowid, old_expire_time), = rows + ((rowid, old_expire_time),) = rows if old_expire_time is None or old_expire_time > now: - sql('UPDATE Cache SET expire_time = ? WHERE rowid = ?', + sql( + 'UPDATE Cache SET expire_time = ? WHERE rowid = ?', (expire_time, rowid), ) return True return False - def add(self, key, value, expire=None, read=False, tag=None, retry=False): """Add `key` and `value` item to cache. @@ -1003,7 +1003,7 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): ).fetchall() if rows: - (rowid, old_filename, old_expire_time), = rows + ((rowid, old_filename, old_expire_time),) = rows if old_expire_time is None or old_expire_time > now: cleanup(filename) @@ -1018,7 +1018,6 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): return True - def incr(self, key, delta=1, default=0, retry=False): """Increment value by delta for item with key. @@ -1059,22 +1058,22 @@ def incr(self, key, delta=1, default=0, retry=False): raise KeyError(key) value = default + delta - columns = ( - (None, None) + self._disk.store(value, False, key=key) + columns = (None, None) + self._disk.store( + value, False, key=key ) self._row_insert(db_key, raw, now, columns) self._cull(now, sql, cleanup) return value - (rowid, expire_time, filename, value), = rows + ((rowid, expire_time, filename, value),) = rows if expire_time is not None and expire_time < now: if default is None: raise KeyError(key) value = default + delta - columns = ( - (None, None) + self._disk.store(value, False, key=key) + columns = (None, None) + self._disk.store( + value, False, key=key ) self._row_update(rowid, now, columns) self._cull(now, sql, cleanup) @@ -1094,7 +1093,6 @@ def incr(self, key, delta=1, default=0, retry=False): return value - def decr(self, key, delta=1, default=0, retry=False): """Decrement value by delta for item with key. @@ -1125,9 +1123,15 @@ def decr(self, key, delta=1, default=0, retry=False): """ return self.incr(key, -delta, default, retry) - - def get(self, key, default=None, read=False, expire_time=False, tag=False, - retry=False): + def get( + self, + key, + default=None, + read=False, + expire_time=False, + tag=False, + retry=False, + ): """Retrieve value from cache. If `key` is missing, return `default`. Raises :exc:`Timeout` error when database timeout occurs and `retry` is @@ -1166,7 +1170,7 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, if not rows: return default - (rowid, db_expire_time, db_tag, mode, filename, db_value), = rows + ((rowid, db_expire_time, db_tag, mode, filename, db_value),) = rows try: value = self._disk.fetch(mode, filename, db_value, read) @@ -1190,8 +1194,9 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, sql(cache_miss) return default - (rowid, db_expire_time, db_tag, - mode, filename, db_value), = rows # noqa: E127 + ( + (rowid, db_expire_time, db_tag, mode, filename, db_value), + ) = rows # noqa: E127 try: value = self._disk.fetch(mode, filename, db_value, read) @@ -1222,7 +1227,6 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, else: return value - def __getitem__(self, key): """Return corresponding value for `key` from cache. @@ -1236,7 +1240,6 @@ def __getitem__(self, key): raise KeyError(key) return value - def read(self, key, retry=False): """Return file handle value corresponding to `key` from cache. @@ -1255,7 +1258,6 @@ def read(self, key, retry=False): raise KeyError(key) return handle - def __contains__(self, key): """Return `True` if `key` matching item is found in cache. @@ -1275,8 +1277,9 @@ def __contains__(self, key): return bool(rows) - - def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # noqa: E501 + def pop( + self, key, default=None, expire_time=False, tag=False, retry=False + ): # noqa: E501 """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. @@ -1314,7 +1317,7 @@ def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # if not rows: return default - (rowid, db_expire_time, db_tag, mode, filename, db_value), = rows + ((rowid, db_expire_time, db_tag, mode, filename, db_value),) = rows sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) @@ -1339,7 +1342,6 @@ def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # else: return value - def __delitem__(self, key, retry=True): """Delete corresponding item for `key` from cache. @@ -1365,13 +1367,12 @@ def __delitem__(self, key, retry=True): if not rows: raise KeyError(key) - (rowid, filename), = rows + ((rowid, filename),) = rows sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) cleanup(filename) return True - def delete(self, key, retry=False): """Delete corresponding item for `key` from cache. @@ -1391,9 +1392,16 @@ def delete(self, key, retry=False): except KeyError: return False - - def push(self, value, prefix=None, side='back', expire=None, read=False, - tag=None, retry=False): + def push( + self, + value, + prefix=None, + side='back', + expire=None, + read=False, + tag=None, + retry=False, + ): """Push `value` onto `side` of queue identified by `prefix` in cache. When prefix is None, integer keys are used. Otherwise, string keys are @@ -1459,10 +1467,10 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, rows = sql(select, (min_key, max_key, raw)).fetchall() if rows: - (key,), = rows + ((key,),) = rows if prefix is not None: - num = int(key[(key.rfind('-') + 1):]) + num = int(key[(key.rfind('-') + 1) :]) else: num = key @@ -1484,9 +1492,15 @@ def push(self, value, prefix=None, side='back', expire=None, read=False, return db_key - - def pull(self, prefix=None, default=(None, None), side='front', - expire_time=False, tag=False, retry=False): + def pull( + self, + prefix=None, + default=(None, None), + side='front', + expire_time=False, + tag=False, + retry=False, + ): """Pull key and value item pair from `side` of queue in cache. When prefix is None, integer keys are used. Otherwise, string keys are @@ -1567,8 +1581,9 @@ def pull(self, prefix=None, default=(None, None), side='front', if not rows: return default - (rowid, key, db_expire, db_tag, mode, name, - db_value), = rows + ( + (rowid, key, db_expire, db_tag, mode, name, db_value), + ) = rows sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) @@ -1598,9 +1613,15 @@ def pull(self, prefix=None, default=(None, None), side='front', else: return key, value - - def peek(self, prefix=None, default=(None, None), side='front', - expire_time=False, tag=False, retry=False): + def peek( + self, + prefix=None, + default=(None, None), + side='front', + expire_time=False, + tag=False, + retry=False, + ): """Peek at key and value item pair from `side` of queue in cache. When prefix is None, integer keys are used. Otherwise, string keys are @@ -1677,8 +1698,9 @@ def peek(self, prefix=None, default=(None, None), side='front', if not rows: return default - (rowid, key, db_expire, db_tag, mode, name, - db_value), = rows + ( + (rowid, key, db_expire, db_tag, mode, name, db_value), + ) = rows if db_expire is not None and db_expire < time.time(): sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) @@ -1707,7 +1729,6 @@ def peek(self, prefix=None, default=(None, None), side='front', else: return key, value - def peekitem(self, last=True, expire_time=False, tag=False, retry=False): """Peek at key and value item pair in cache based on iteration order. @@ -1749,8 +1770,18 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): if not rows: raise KeyError('dictionary is empty') - (rowid, db_key, raw, db_expire, db_tag, mode, name, - db_value), = rows + ( + ( + rowid, + db_key, + raw, + db_expire, + db_tag, + mode, + name, + db_value, + ), + ) = rows if db_expire is not None and db_expire < time.time(): sql('DELETE FROM Cache WHERE rowid = ?', (rowid,)) @@ -1778,7 +1809,6 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): else: return key, value - def memoize(self, name=None, typed=False, expire=None, tag=None): """Memoizing cache decorator. @@ -1871,7 +1901,6 @@ def __cache_key__(*args, **kwargs): return decorator - def check(self, fix=False, retry=False): """Check database and file system consistency. @@ -1901,7 +1930,7 @@ def check(self, fix=False, retry=False): rows = sql('PRAGMA integrity_check').fetchall() if len(rows) != 1 or rows[0][0] != u'ok': - for message, in rows: + for (message,) in rows: warnings.warn(message) if fix: @@ -1932,7 +1961,8 @@ def check(self, fix=False, retry=False): warnings.warn(message % args) if fix: - sql('UPDATE Cache SET size = ?' + sql( + 'UPDATE Cache SET size = ?' ' WHERE rowid = ?', (real_size, rowid), ) @@ -1973,14 +2003,15 @@ def check(self, fix=False, retry=False): # Check Settings.count against count of Cache rows. self.reset('count') - (count,), = sql('SELECT COUNT(key) FROM Cache').fetchall() + ((count,),) = sql('SELECT COUNT(key) FROM Cache').fetchall() if self.count != count: message = 'Settings.count != COUNT(Cache.key); %d != %d' warnings.warn(message % (self.count, count)) if fix: - sql('UPDATE Settings SET value = ? WHERE key = ?', + sql( + 'UPDATE Settings SET value = ? WHERE key = ?', (count, 'count'), ) @@ -1988,20 +2019,20 @@ def check(self, fix=False, retry=False): self.reset('size') select_size = 'SELECT COALESCE(SUM(size), 0) FROM Cache' - (size,), = sql(select_size).fetchall() + ((size,),) = sql(select_size).fetchall() if self.size != size: message = 'Settings.size != SUM(Cache.size); %d != %d' warnings.warn(message % (self.size, size)) if fix: - sql('UPDATE Settings SET value = ? WHERE key =?', + sql( + 'UPDATE Settings SET value = ? WHERE key =?', (size, 'size'), ) return warns - def create_tag_index(self): """Create tag index on cache database. @@ -2014,7 +2045,6 @@ def create_tag_index(self): sql('CREATE INDEX IF NOT EXISTS Cache_tag_rowid ON Cache(tag, rowid)') self.reset('tag_index', 1) - def drop_tag_index(self): """Drop tag index on cache database. @@ -2025,7 +2055,6 @@ def drop_tag_index(self): sql('DROP INDEX IF EXISTS Cache_tag_rowid') self.reset('tag_index', 0) - def evict(self, tag, retry=False): """Remove items with matching `tag` from cache. @@ -2053,7 +2082,6 @@ def evict(self, tag, retry=False): args = [tag, 0, 100] return self._select_delete(select, args, arg_index=1, retry=retry) - def expire(self, now=None, retry=False): """Remove expired items from cache. @@ -2081,7 +2109,6 @@ def expire(self, now=None, retry=False): args = [0, now or time.time(), 100] return self._select_delete(select, args, row_index=1, retry=retry) - def cull(self, retry=False): """Cull items from cache until volume is less than size limit. @@ -2130,14 +2157,13 @@ def cull(self, retry=False): ) sql(delete, (10,)) - for filename, in rows: + for (filename,) in rows: cleanup(filename) except Timeout: raise Timeout(count) from None return count - def clear(self, retry=False): """Remove all items from cache. @@ -2164,9 +2190,9 @@ def clear(self, retry=False): args = [0, 100] return self._select_delete(select, args, retry=retry) - - def _select_delete(self, select, args, row_index=0, arg_index=0, - retry=False): + def _select_delete( + self, select, args, row_index=0, arg_index=0, retry=False + ): count = 0 delete = 'DELETE FROM Cache WHERE rowid IN (%s)' @@ -2190,7 +2216,6 @@ def _select_delete(self, select, args, row_index=0, arg_index=0, return count - def iterkeys(self, reverse=False): """Iterate Cache keys in database sort order. @@ -2234,7 +2259,7 @@ def iterkeys(self, reverse=False): row = sql(select).fetchall() if row: - (key, raw), = row + ((key, raw),) = row else: return @@ -2249,11 +2274,10 @@ def iterkeys(self, reverse=False): for key, raw in rows: yield _disk_get(key, raw) - def _iter(self, ascending=True): sql = self._sql rows = sql('SELECT MAX(rowid) FROM Cache').fetchall() - (max_rowid,), = rows + ((max_rowid,),) = rows yield # Signal ready. if max_rowid is None: @@ -2283,21 +2307,18 @@ def _iter(self, ascending=True): for rowid, key, raw in rows: yield _disk_get(key, raw) - def __iter__(self): "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." iterator = self._iter(ascending=False) next(iterator) return iterator - def stats(self, enable=True, reset=False): """Return cache statistics hits and misses. @@ -2317,22 +2338,18 @@ def stats(self, enable=True, reset=False): return result - def volume(self): """Return estimated total size of cache on disk. :return: size in bytes """ - (page_count,), = self._sql('PRAGMA page_count').fetchall() + ((page_count,),) = self._sql('PRAGMA page_count').fetchall() total_size = self._page_size * page_count + self.reset('size') return total_size - def close(self): - """Close database connection. - - """ + """Close database connection.""" con = getattr(self._local, 'con', None) if con is None: @@ -2345,31 +2362,25 @@ def close(self): except AttributeError: pass - def __enter__(self): # Create connection in thread. # pylint: disable=unused-variable connection = self._con # noqa return self - def __exit__(self, *exception): self.close() - def __len__(self): "Count of items in cache including expired items." return self.reset('count') - def __getstate__(self): return (self.directory, self.timeout, type(self.disk)) - def __setstate__(self, state): self.__init__(*state) - def reset(self, key, value=ENOVAL, update=True): """Reset `key` and `value` item from Settings table. @@ -2403,7 +2414,7 @@ def reset(self, key, value=ENOVAL, update=True): if value is ENOVAL: select = 'SELECT value FROM Settings WHERE key = ?' - (value,), = sql_retry(select, (key,)).fetchall() + ((value,),) = sql_retry(select, (key,)).fetchall() setattr(self, key, value) return value @@ -2431,7 +2442,9 @@ def reset(self, key, value=ENOVAL, update=True): while True: try: try: - (old_value,), = sql('PRAGMA %s' % (pragma)).fetchall() + ((old_value,),) = sql( + 'PRAGMA %s' % (pragma) + ).fetchall() update = old_value != value except ValueError: update = True diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 329b966..2f1db07 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -15,6 +15,7 @@ class DjangoCache(BaseCache): "Django-compatible disk and file backed cache." + def __init__(self, directory, params): """Initialize DjangoCache instance. @@ -28,13 +29,11 @@ def __init__(self, directory, params): options = params.get('OPTIONS', {}) self._cache = FanoutCache(directory, shards, timeout, **options) - @property def directory(self): """Cache directory.""" return self._cache.directory - def cache(self, name): """Return Cache with given `name` in subdirectory. @@ -44,7 +43,6 @@ def cache(self, name): """ return self._cache.cache(name) - def deque(self, name): """Return Deque with given `name` in subdirectory. @@ -54,7 +52,6 @@ def deque(self, name): """ return self._cache.deque(name) - def index(self, name): """Return Index with given `name` in subdirectory. @@ -64,9 +61,16 @@ def index(self, name): """ return self._cache.index(name) - - def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, - read=False, tag=None, retry=True): + def add( + self, + key, + value, + timeout=DEFAULT_TIMEOUT, + version=None, + read=False, + tag=None, + retry=True, + ): """Set a value in the cache if the key does not already exist. If timeout is given, that timeout will be used for the key; otherwise the default cache timeout will be used. @@ -89,9 +93,16 @@ def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, timeout = self.get_backend_timeout(timeout=timeout) return self._cache.add(key, value, timeout, read, tag, retry) - - def get(self, key, default=None, version=None, read=False, - expire_time=False, tag=False, retry=False): + def get( + self, + key, + default=None, + version=None, + read=False, + expire_time=False, + tag=False, + retry=False, + ): """Fetch a given key from the cache. If the key does not exist, return default, which itself defaults to None. @@ -111,7 +122,6 @@ def get(self, key, default=None, version=None, read=False, key = self.make_key(key, version=version) return self._cache.get(key, default, read, expire_time, tag, retry) - def read(self, key, version=None): """Return file handle corresponding to `key` from Cache. @@ -124,9 +134,16 @@ def read(self, key, version=None): key = self.make_key(key, version=version) return self._cache.read(key) - - def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, - read=False, tag=None, retry=True): + def set( + self, + key, + value, + timeout=DEFAULT_TIMEOUT, + version=None, + read=False, + tag=None, + retry=True, + ): """Set a value in the cache. If timeout is given, that timeout will be used for the key; otherwise the default cache timeout will be used. @@ -146,7 +163,6 @@ def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None, timeout = self.get_backend_timeout(timeout=timeout) return self._cache.set(key, value, timeout, read, tag, retry) - def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, retry=True): """Touch a key in the cache. If timeout is given, that timeout will be used for the key; otherwise the default cache timeout will be used. @@ -164,9 +180,15 @@ def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, retry=True): timeout = self.get_backend_timeout(timeout=timeout) return self._cache.touch(key, timeout, retry) - - def pop(self, key, default=None, version=None, expire_time=False, - tag=False, retry=True): + def pop( + self, + key, + default=None, + version=None, + expire_time=False, + tag=False, + retry=True, + ): """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. @@ -186,7 +208,6 @@ def pop(self, key, default=None, version=None, expire_time=False, key = self.make_key(key, version=version) return self._cache.pop(key, default, expire_time, tag, retry) - def delete(self, key, version=None, retry=True): """Delete a key from the cache, failing silently. @@ -200,7 +221,6 @@ def delete(self, key, version=None, retry=True): key = self.make_key(key, version=version) self._cache.delete(key, retry) - def incr(self, key, delta=1, version=None, default=None, retry=True): """Increment value by delta for item with key. @@ -230,7 +250,6 @@ def incr(self, key, delta=1, version=None, default=None, retry=True): except KeyError: raise ValueError("Key '%s' not found" % key) from None - def decr(self, key, delta=1, version=None, default=None, retry=True): """Decrement value by delta for item with key. @@ -259,7 +278,6 @@ def decr(self, key, delta=1, version=None, default=None, retry=True): # pylint: disable=arguments-differ return self.incr(key, -delta, version, default, retry) - def has_key(self, key, version=None): """Returns True if the key is in the cache and has not expired. @@ -271,7 +289,6 @@ def has_key(self, key, version=None): key = self.make_key(key, version=version) return key in self._cache - def expire(self): """Remove expired items from cache. @@ -280,7 +297,6 @@ def expire(self): """ return self._cache.expire() - def stats(self, enable=True, reset=False): """Return cache statistics hits and misses. @@ -291,7 +307,6 @@ def stats(self, enable=True, reset=False): """ return self._cache.stats(enable=enable, reset=reset) - def create_tag_index(self): """Create tag index on cache database. @@ -302,7 +317,6 @@ def create_tag_index(self): """ self._cache.create_tag_index() - def drop_tag_index(self): """Drop tag index on cache database. @@ -311,7 +325,6 @@ def drop_tag_index(self): """ self._cache.drop_tag_index() - def evict(self, tag): """Remove items with matching `tag` from cache. @@ -321,7 +334,6 @@ def evict(self, tag): """ return self._cache.evict(tag) - def cull(self): """Cull items from cache until volume is less than size limit. @@ -330,18 +342,15 @@ def cull(self): """ return self._cache.cull() - def clear(self): "Remove *all* values from the cache at once." return self._cache.clear() - def close(self, **kwargs): "Close the cache connection." # pylint: disable=unused-argument self._cache.close() - def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT): """Return seconds to expiration. @@ -356,9 +365,14 @@ def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT): timeout = -1 return None if timeout is None else timeout - - def memoize(self, name=None, timeout=DEFAULT_TIMEOUT, version=None, - typed=False, tag=None): + def memoize( + self, + name=None, + timeout=DEFAULT_TIMEOUT, + version=None, + typed=False, + tag=None, + ): """Memoizing cache decorator. Decorator to wrap callable with memoizing function using cache. @@ -418,7 +432,12 @@ def wrapper(*args, **kwargs): ) if valid_timeout: self.set( - key, result, timeout, version, tag=tag, retry=True, + key, + result, + timeout, + version, + tag=tag, + retry=True, ) return result diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 9870c06..cc40c07 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -15,8 +15,10 @@ class FanoutCache(object): "Cache that shards keys and values." - def __init__(self, directory=None, shards=8, timeout=0.010, disk=Disk, - **settings): + + def __init__( + self, directory=None, shards=8, timeout=0.010, disk=Disk, **settings + ): """Initialize cache instance. :param str directory: cache directory @@ -52,20 +54,17 @@ def __init__(self, directory=None, shards=8, timeout=0.010, disk=Disk, self._deques = {} self._indexes = {} - @property def directory(self): """Cache directory.""" return self._directory - def __getattr__(self, name): safe_names = {'timeout', 'disk'} valid_name = name in DEFAULT_SETTINGS or name in safe_names assert valid_name, 'cannot access {} in cache shard'.format(name) return getattr(self._shards[0], name) - @cl.contextmanager def transact(self, retry=True): """Context manager to perform a transaction by locking the cache. @@ -99,7 +98,6 @@ def transact(self, retry=True): stack.enter_context(shard_transaction) yield - def set(self, key, value, expire=None, read=False, tag=None, retry=False): """Set `key` and `value` item in cache. @@ -126,7 +124,6 @@ def set(self, key, value, expire=None, read=False, tag=None, retry=False): except Timeout: return False - def __setitem__(self, key, value): """Set `key` and `value` item in cache. @@ -140,7 +137,6 @@ def __setitem__(self, key, value): shard = self._shards[index] shard[key] = value - def touch(self, key, expire=None, retry=False): """Touch `key` in cache and update `expire` time. @@ -161,7 +157,6 @@ def touch(self, key, expire=None, retry=False): except Timeout: return False - def add(self, key, value, expire=None, read=False, tag=None, retry=False): """Add `key` and `value` item to cache. @@ -193,7 +188,6 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False): except Timeout: return False - def incr(self, key, delta=1, default=0, retry=False): """Increment value by delta for item with key. @@ -225,7 +219,6 @@ def incr(self, key, delta=1, default=0, retry=False): except Timeout: return None - def decr(self, key, delta=1, default=0, retry=False): """Decrement value by delta for item with key. @@ -260,9 +253,15 @@ def decr(self, key, delta=1, default=0, retry=False): except Timeout: return None - - def get(self, key, default=None, read=False, expire_time=False, tag=False, - retry=False): + def get( + self, + key, + default=None, + read=False, + expire_time=False, + tag=False, + retry=False, + ): """Retrieve value from cache. If `key` is missing, return `default`. If database timeout occurs then returns `default` unless `retry` is set @@ -286,7 +285,6 @@ def get(self, key, default=None, read=False, expire_time=False, tag=False, except (Timeout, sqlite3.OperationalError): return default - def __getitem__(self, key): """Return corresponding value for `key` from cache. @@ -301,7 +299,6 @@ def __getitem__(self, key): shard = self._shards[index] return shard[key] - def read(self, key): """Return file handle corresponding to `key` from cache. @@ -315,7 +312,6 @@ def read(self, key): raise KeyError(key) return handle - def __contains__(self, key): """Return `True` if `key` matching item is found in cache. @@ -327,8 +323,9 @@ def __contains__(self, key): shard = self._shards[index] return key in shard - - def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # noqa: E501 + def pop( + self, key, default=None, expire_time=False, tag=False, retry=False + ): # noqa: E501 """Remove corresponding item for `key` from cache and return value. If `key` is missing, return `default`. @@ -354,7 +351,6 @@ def pop(self, key, default=None, expire_time=False, tag=False, retry=False): # except Timeout: return default - def delete(self, key, retry=False): """Delete corresponding item for `key` from cache. @@ -375,7 +371,6 @@ def delete(self, key, retry=False): except Timeout: return False - def __delitem__(self, key): """Delete corresponding item for `key` from cache. @@ -389,7 +384,6 @@ def __delitem__(self, key): shard = self._shards[index] del shard[key] - def check(self, fix=False, retry=False): """Check database and file system consistency. @@ -413,7 +407,6 @@ def check(self, fix=False, retry=False): warnings = (shard.check(fix, retry) for shard in self._shards) return functools.reduce(operator.iadd, warnings, []) - def expire(self, retry=False): """Remove expired items from cache. @@ -426,7 +419,6 @@ def expire(self, retry=False): """ return self._remove('expire', args=(time.time(),), retry=retry) - def create_tag_index(self): """Create tag index on cache database. @@ -438,7 +430,6 @@ def create_tag_index(self): for shard in self._shards: shard.create_tag_index() - def drop_tag_index(self): """Drop tag index on cache database. @@ -448,7 +439,6 @@ def drop_tag_index(self): for shard in self._shards: shard.drop_tag_index() - def evict(self, tag, retry=False): """Remove items with matching `tag` from cache. @@ -462,7 +452,6 @@ def evict(self, tag, retry=False): """ return self._remove('evict', args=(tag,), retry=retry) - def cull(self, retry=False): """Cull items from cache until volume is less than size limit. @@ -475,7 +464,6 @@ def cull(self, retry=False): """ return self._remove('cull', retry=retry) - def clear(self, retry=False): """Remove all items from cache. @@ -488,7 +476,6 @@ def clear(self, retry=False): """ return self._remove('clear', retry=retry) - def _remove(self, name, args=(), retry=False): total = 0 for shard in self._shards: @@ -503,7 +490,6 @@ def _remove(self, name, args=(), retry=False): break return total - def stats(self, enable=True, reset=False): """Return cache statistics hits and misses. @@ -517,7 +503,6 @@ def stats(self, enable=True, reset=False): total_misses = sum(misses for _, misses in results) return total_hits, total_misses - def volume(self): """Return estimated total size of cache on disk. @@ -526,7 +511,6 @@ def volume(self): """ return sum(shard.volume() for shard in self._shards) - def close(self): "Close database connection." for shard in self._shards: @@ -535,40 +519,32 @@ def close(self): self._deques.clear() self._indexes.clear() - def __enter__(self): return self - def __exit__(self, *exception): self.close() - def __getstate__(self): return (self._directory, self._count, self.timeout, type(self.disk)) - def __setstate__(self, state): self.__init__(*state) - def __iter__(self): "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." 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." return sum(len(shard) for shard in self._shards) - def reset(self, key, value=ENOVAL): """Reset `key` and `value` item from Settings table. @@ -597,7 +573,6 @@ def reset(self, key, value=ENOVAL): break return result - def cache(self, name): """Return Cache with given `name` in subdirectory. @@ -627,7 +602,6 @@ def cache(self, name): _caches[name] = temp return temp - def deque(self, name): """Return Deque with given `name` in subdirectory. @@ -657,7 +631,6 @@ def deque(self, name): _deques[name] = deque return deque - def index(self, name): """Return Index with given `name` in subdirectory. diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 6fc072b..ac5a5b0 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -15,6 +15,7 @@ def _make_compare(seq_op, doc): "Make compare method with Sequence semantics." + def compare(self, that): "Compare method for deque and sequence." if not isinstance(that, Sequence): @@ -70,6 +71,7 @@ class Deque(Sequence): [3, 2, 1, 0, 0, -1, -2, -3] """ + def __init__(self, iterable=(), directory=None): """Initialize deque instance. @@ -83,7 +85,6 @@ def __init__(self, iterable=(), directory=None): self._cache = Cache(directory, eviction_policy='none') self.extend(iterable) - @classmethod def fromcache(cls, cache, iterable=()): """Initialize deque using `cache`. @@ -110,19 +111,16 @@ def fromcache(cls, cache, iterable=()): self.extend(iterable) return self - @property def cache(self): "Cache used by deque." return self._cache - @property def directory(self): "Directory path where deque is stored." return self._cache.directory - def _index(self, index, func): len_self = len(self) @@ -153,7 +151,6 @@ def _index(self, index, func): raise IndexError('deque index out of range') - def __getitem__(self, index): """deque.__getitem__(index) <==> deque[index] @@ -176,7 +173,6 @@ def __getitem__(self, index): """ return self._index(index, self._cache.__getitem__) - def __setitem__(self, index, value): """deque.__setitem__(index, value) <==> deque[index] = value @@ -195,10 +191,11 @@ def __setitem__(self, index, value): :raises IndexError: if index out of range """ + def _set_value(key): return self._cache.__setitem__(key, value) - self._index(index, _set_value) + self._index(index, _set_value) def __delitem__(self, index): """deque.__delitem__(index) <==> del deque[index] @@ -219,7 +216,6 @@ def __delitem__(self, index): """ self._index(index, self._cache.__delitem__) - def __repr__(self): """deque.__repr__() <==> repr(deque) @@ -229,7 +225,6 @@ def __repr__(self): name = type(self).__name__ return '{0}(directory={1!r})'.format(name, self.directory) - __eq__ = _make_compare(op.eq, 'equal to') __ne__ = _make_compare(op.ne, 'not equal to') __lt__ = _make_compare(op.lt, 'less than') @@ -237,7 +232,6 @@ def __repr__(self): __le__ = _make_compare(op.le, 'less than or equal to') __ge__ = _make_compare(op.ge, 'greater than or equal to') - def __iadd__(self, iterable): """deque.__iadd__(iterable) <==> deque += iterable @@ -250,7 +244,6 @@ def __iadd__(self, iterable): self.extend(iterable) return self - def __iter__(self): """deque.__iter__() <==> iter(deque) @@ -265,7 +258,6 @@ def __iter__(self): except KeyError: pass - def __len__(self): """deque.__len__() <==> len(deque) @@ -274,7 +266,6 @@ def __len__(self): """ return len(self._cache) - def __reversed__(self): """deque.__reversed__() <==> reversed(deque) @@ -297,15 +288,12 @@ def __reversed__(self): except KeyError: pass - def __getstate__(self): return self.directory - def __setstate__(self, state): self.__init__(directory=state) - def append(self, value): """Add `value` to back of deque. @@ -321,7 +309,6 @@ def append(self, value): """ self._cache.push(value, retry=True) - def appendleft(self, value): """Add `value` to front of deque. @@ -337,7 +324,6 @@ def appendleft(self, value): """ self._cache.push(value, side='front', retry=True) - def clear(self): """Remove all elements from deque. @@ -351,7 +337,6 @@ def clear(self): """ self._cache.clear(retry=True) - def count(self, value): """Return number of occurrences of `value` in deque. @@ -370,7 +355,6 @@ def count(self, value): """ return sum(1 for item in self if value == item) - def extend(self, iterable): """Extend back side of deque with values from `iterable`. @@ -380,7 +364,6 @@ def extend(self, iterable): for value in iterable: self.append(value) - def extendleft(self, iterable): """Extend front side of deque with value from `iterable`. @@ -395,7 +378,6 @@ def extendleft(self, iterable): for value in iterable: self.appendleft(value) - def peek(self): """Peek at value at back of deque. @@ -422,7 +404,6 @@ def peek(self): raise IndexError('peek from an empty deque') return value - def peekleft(self): """Peek at value at front of deque. @@ -449,7 +430,6 @@ def peekleft(self): raise IndexError('peek from an empty deque') return value - def pop(self): """Remove and return value at back of deque. @@ -476,7 +456,6 @@ def pop(self): raise IndexError('pop from an empty deque') return value - def popleft(self): """Remove and return value at front of deque. @@ -501,7 +480,6 @@ def popleft(self): raise IndexError('pop from an empty deque') return value - def remove(self, value): """Remove first occurrence of `value` in deque. @@ -539,7 +517,6 @@ def remove(self, value): raise ValueError('deque.remove(value): value not in deque') - def reverse(self): """Reverse deque in place. @@ -562,7 +539,6 @@ def reverse(self): del temp rmtree(directory) - def rotate(self, steps=1): """Rotate deque right by `steps`. @@ -611,10 +587,8 @@ def rotate(self, steps=1): else: self.append(value) - __hash__ = None - @contextmanager def transact(self): """Context manager to perform a transaction by locking the deque. @@ -664,6 +638,7 @@ class Index(MutableMapping): ('c', 3) """ + def __init__(self, *args, **kwargs): """Initialize index in directory and update items. @@ -691,7 +666,6 @@ def __init__(self, *args, **kwargs): self._cache = Cache(directory, eviction_policy='none') self.update(*args, **kwargs) - @classmethod def fromcache(cls, cache, *args, **kwargs): """Initialize index using `cache` and update items. @@ -719,19 +693,16 @@ def fromcache(cls, cache, *args, **kwargs): self.update(*args, **kwargs) return self - @property def cache(self): "Cache used by index." return self._cache - @property def directory(self): "Directory path where items are stored." return self._cache.directory - def __getitem__(self, key): """index.__getitem__(key) <==> index[key] @@ -755,7 +726,6 @@ def __getitem__(self, key): """ return self._cache[key] - def __setitem__(self, key, value): """index.__setitem__(key, value) <==> index[key] = value @@ -773,7 +743,6 @@ def __setitem__(self, key, value): """ self._cache[key] = value - def __delitem__(self, key): """index.__delitem__(key) <==> del index[key] @@ -796,7 +765,6 @@ def __delitem__(self, key): """ del self._cache[key] - def setdefault(self, key, default=None): """Set and get value for `key` in index using `default`. @@ -821,7 +789,6 @@ def setdefault(self, key, default=None): except KeyError: _cache.add(key, default, retry=True) - def peekitem(self, last=True): """Peek at key and value item pair in index based on iteration order. @@ -840,7 +807,6 @@ def peekitem(self, last=True): """ return self._cache.peekitem(last, retry=True) - def pop(self, key, default=ENOVAL): """Remove corresponding item for `key` from index and return value. @@ -871,7 +837,6 @@ def pop(self, key, default=ENOVAL): raise KeyError(key) return value - def popitem(self, last=True): """Remove and return item pair. @@ -906,7 +871,6 @@ def popitem(self, last=True): return key, value - def push(self, value, prefix=None, side='back'): """Push `value` onto `side` of queue in index identified by `prefix`. @@ -938,7 +902,6 @@ def push(self, value, prefix=None, side='back'): """ return self._cache.push(value, prefix, side, retry=True) - def pull(self, prefix=None, default=(None, None), side='front'): """Pull key and value item pair from `side` of queue in index. @@ -979,7 +942,6 @@ def pull(self, prefix=None, default=(None, None), side='front'): """ return self._cache.pull(prefix, default, side, retry=True) - def clear(self): """Remove all items from index. @@ -993,7 +955,6 @@ def clear(self): """ self._cache.clear(retry=True) - def __iter__(self): """index.__iter__() <==> iter(index) @@ -1002,7 +963,6 @@ def __iter__(self): """ return iter(self._cache) - def __reversed__(self): """index.__reversed__() <==> reversed(index) @@ -1019,7 +979,6 @@ def __reversed__(self): """ return reversed(self._cache) - def __len__(self): """index.__len__() <==> len(index) @@ -1028,7 +987,6 @@ def __len__(self): """ return len(self._cache) - def keys(self): """Set-like object providing a view of index keys. @@ -1043,7 +1001,6 @@ def keys(self): """ return KeysView(self) - def values(self): """Set-like object providing a view of index values. @@ -1058,7 +1015,6 @@ def values(self): """ return ValuesView(self) - def items(self): """Set-like object providing a view of index items. @@ -1073,18 +1029,14 @@ def items(self): """ return ItemsView(self) - __hash__ = None - def __getstate__(self): return self.directory - def __setstate__(self, state): self.__init__(state) - def __eq__(self, other): """index.__eq__(other) <==> index == other @@ -1118,7 +1070,6 @@ def __eq__(self, other): else: return all(self[key] == other.get(key, ENOVAL) for key in self) - def __ne__(self, other): """index.__ne__(other) <==> index != other @@ -1142,7 +1093,6 @@ def __ne__(self, other): """ return not self == other - def memoize(self, name=None, typed=False): """Memoizing cache decorator. @@ -1199,7 +1149,6 @@ def memoize(self, name=None, typed=False): """ return self._cache.memoize(name, typed) - @contextmanager def transact(self): """Context manager to perform a transaction by locking the index. @@ -1227,7 +1176,6 @@ def transact(self): with self._cache.transact(retry=True): yield - def __repr__(self): """index.__repr__() <==> repr(index) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 85509c7..3acddd8 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -32,6 +32,7 @@ class Averager(object): None """ + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key @@ -45,7 +46,10 @@ def add(self, value): total += value count += 1 self._cache.set( - self._key, (total, count), expire=self._expire, tag=self._tag, + self._key, + (total, count), + expire=self._expire, + tag=self._tag, ) def get(self): @@ -71,6 +75,7 @@ class Lock(object): ... pass """ + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key @@ -124,6 +129,7 @@ class RLock(object): AssertionError: cannot release un-acquired lock """ + def __init__(self, cache, key, expire=None, tag=None): self._cache = cache self._key = key @@ -141,8 +147,10 @@ def acquire(self): value, count = self._cache.get(self._key, default=(None, 0)) if pid_tid == value or count == 0: self._cache.set( - self._key, (pid_tid, count + 1), - expire=self._expire, tag=self._tag, + self._key, + (pid_tid, count + 1), + expire=self._expire, + tag=self._tag, ) return time.sleep(0.001) @@ -158,8 +166,10 @@ def release(self): is_owned = pid_tid == value and count > 0 assert is_owned, 'cannot release un-acquired lock' self._cache.set( - self._key, (value, count - 1), - expire=self._expire, tag=self._tag, + self._key, + (value, count - 1), + expire=self._expire, + tag=self._tag, ) def __enter__(self): @@ -187,6 +197,7 @@ class BoundedSemaphore(object): AssertionError: cannot release un-acquired semaphore """ + def __init__(self, cache, key, value=1, expire=None, tag=None): self._cache = cache self._key = key @@ -201,8 +212,10 @@ def acquire(self): value = self._cache.get(self._key, default=self._value) if value > 0: self._cache.set( - self._key, value - 1, - expire=self._expire, tag=self._tag, + self._key, + value - 1, + expire=self._expire, + tag=self._tag, ) return time.sleep(0.001) @@ -214,7 +227,10 @@ def release(self): assert self._value > value, 'cannot release un-acquired semaphore' value += 1 self._cache.set( - self._key, value, expire=self._expire, tag=self._tag, + self._key, + value, + expire=self._expire, + tag=self._tag, ) def __enter__(self): @@ -224,8 +240,16 @@ def __exit__(self, *exc_info): self.release() -def throttle(cache, count, seconds, name=None, expire=None, tag=None, - time_func=time.time, sleep_func=time.sleep): +def throttle( + cache, + count, + seconds, + name=None, + expire=None, + tag=None, + time_func=time.time, + sleep_func=time.sleep, +): """Decorator to throttle calls to function. >>> import diskcache, time @@ -242,6 +266,7 @@ def throttle(cache, count, seconds, name=None, expire=None, tag=None, True """ + def decorator(func): rate = count / float(seconds) key = full_name(func) if name is None else name @@ -298,6 +323,7 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): >>> pool.terminate() """ + def decorator(func): key = full_name(func) if name is None else name lock = lock_factory(cache, key, expire=expire, tag=tag) @@ -385,7 +411,10 @@ def wrapper(*args, **kwargs): "Wrapper for callable to cache arguments and return values." key = wrapper.__cache_key__(*args, **kwargs) pair, expire_time = cache.get( - key, default=ENOVAL, expire_time=True, retry=True, + key, + default=ENOVAL, + expire_time=True, + retry=True, ) if pair is not ENOVAL: @@ -400,7 +429,10 @@ def wrapper(*args, **kwargs): thread_key = key + (ENOVAL,) thread_added = cache.add( - thread_key, None, expire=delta, retry=True, + thread_key, + None, + expire=delta, + retry=True, ) if thread_added: @@ -409,8 +441,13 @@ def recompute(): with cache: pair = timer(*args, **kwargs) cache.set( - key, pair, expire=expire, tag=tag, retry=True, + key, + pair, + expire=expire, + tag=tag, + retry=True, ) + thread = threading.Thread(target=recompute) thread.daemon = True thread.start() diff --git a/setup.py b/setup.py index f8c465e..2d6daa3 100644 --- a/setup.py +++ b/setup.py @@ -10,8 +10,10 @@ def finalize_options(self): TestCommand.finalize_options(self) self.test_args = [] self.test_suite = True + def run_tests(self): import tox + errno = tox.cmdline(self.test_args) exit(errno) diff --git a/tests/benchmark_core.py b/tests/benchmark_core.py index 2eeae3b..a048b79 100644 --- a/tests/benchmark_core.py +++ b/tests/benchmark_core.py @@ -32,19 +32,30 @@ import diskcache -caches.append(('diskcache.Cache', diskcache.Cache, ('tmp',), {},)) -caches.append(( - 'diskcache.FanoutCache(shards=4, timeout=1.0)', - diskcache.FanoutCache, - ('tmp',), - {'shards': 4, 'timeout': 1.0} -)) -caches.append(( - 'diskcache.FanoutCache(shards=8, timeout=0.010)', - diskcache.FanoutCache, - ('tmp',), - {'shards': 8, 'timeout': 0.010} -)) +caches.append( + ( + 'diskcache.Cache', + diskcache.Cache, + ('tmp',), + {}, + ) +) +caches.append( + ( + 'diskcache.FanoutCache(shards=4, timeout=1.0)', + diskcache.FanoutCache, + ('tmp',), + {'shards': 4, 'timeout': 1.0}, + ) +) +caches.append( + ( + 'diskcache.FanoutCache(shards=8, timeout=0.010)', + diskcache.FanoutCache, + ('tmp',), + {'shards': 8, 'timeout': 0.010}, + ) +) ############################################################################### @@ -54,12 +65,17 @@ try: import pylibmc - caches.append(( - 'pylibmc.Client', - pylibmc.Client, - (['127.0.0.1'],), - {'binary': True, 'behaviors': {'tcp_nodelay': True, 'ketama': True}}, - )) + caches.append( + ( + 'pylibmc.Client', + pylibmc.Client, + (['127.0.0.1'],), + { + 'binary': True, + 'behaviors': {'tcp_nodelay': True, 'ketama': True}, + }, + ) + ) except ImportError: warnings.warn('skipping pylibmc') @@ -71,12 +87,14 @@ try: import redis - caches.append(( - 'redis.StrictRedis', - redis.StrictRedis, - (), - {'host': 'localhost', 'port': 6379, 'db': 0}, - )) + caches.append( + ( + 'redis.StrictRedis', + redis.StrictRedis, + (), + {'host': 'localhost', 'port': 6379, 'db': 0}, + ) + ) except ImportError: warnings.warn('skipping redis') @@ -84,7 +102,7 @@ def worker(num, kind, args, kwargs): random.seed(num) - time.sleep(0.01) # Let other processes start. + time.sleep(0.01) # Let other processes start. obj = kind(*args, **kwargs) @@ -173,19 +191,31 @@ def dispatch(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - '-p', '--processes', type=int, default=PROCS, + '-p', + '--processes', + type=int, + default=PROCS, help='Number of processes to start', ) parser.add_argument( - '-n', '--operations', type=float, default=OPS, + '-n', + '--operations', + type=float, + default=OPS, help='Number of operations to perform', ) parser.add_argument( - '-r', '--range', type=int, default=RANGE, + '-r', + '--range', + type=int, + default=RANGE, help='Range of keys', ) parser.add_argument( - '-w', '--warmup', type=float, default=WARMUP, + '-w', + '--warmup', + type=float, + default=WARMUP, help='Number of warmup operations before timings', ) diff --git a/tests/benchmark_djangocache.py b/tests/benchmark_djangocache.py index 0512752..0de0424 100644 --- a/tests/benchmark_djangocache.py +++ b/tests/benchmark_djangocache.py @@ -27,6 +27,7 @@ def setup(): os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings_benchmark') import django + django.setup() @@ -41,7 +42,7 @@ def worker(num, name): timings = co.defaultdict(list) - time.sleep(0.01) # Let other processes start. + time.sleep(0.01) # Let other processes start. for count in range(OPS): key = str(random.randrange(RANGE)).encode('utf-8') @@ -140,19 +141,31 @@ def dispatch(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - '-p', '--processes', type=int, default=PROCS, + '-p', + '--processes', + type=int, + default=PROCS, help='Number of processes to start', ) parser.add_argument( - '-n', '--operations', type=float, default=OPS, + '-n', + '--operations', + type=float, + default=OPS, help='Number of operations to perform', ) parser.add_argument( - '-r', '--range', type=int, default=RANGE, + '-r', + '--range', + type=int, + default=RANGE, help='Range of keys', ) parser.add_argument( - '-w', '--warmup', type=float, default=WARMUP, + '-w', + '--warmup', + type=float, + default=WARMUP, help='Number of warmup operations before timings', ) diff --git a/tests/benchmark_glob.py b/tests/benchmark_glob.py index 82237de..9c23104 100644 --- a/tests/benchmark_glob.py +++ b/tests/benchmark_glob.py @@ -26,11 +26,9 @@ for value in range(count): with open(op.join('tmp', '%s.tmp' % value), 'wb') as writer: pass - + delta = timeit.timeit( - stmt="glob.glob1('tmp', '*.tmp')", - setup='import glob', - number=100 + stmt="glob.glob1('tmp', '*.tmp')", setup='import glob', number=100 ) print(template % (count, secs(delta))) diff --git a/tests/issue_109.py b/tests/issue_109.py index a650b4c..a5355b1 100644 --- a/tests/issue_109.py +++ b/tests/issue_109.py @@ -8,6 +8,7 @@ def main(): import argparse + parser = argparse.ArgumentParser() parser.add_argument('--cache-dir', default='/tmp/test') parser.add_argument('--iterations', type=int, default=100) @@ -31,7 +32,7 @@ def main(): delays.append(diff) # Discard warmup delays, first two iterations. - del delays[:(len(values) * 2)] + del delays[: (len(values) * 2)] # Convert seconds to microseconds. delays = sorted(delay * 1e6 for delay in delays) diff --git a/tests/issue_85.py b/tests/issue_85.py index a52de97..db32046 100644 --- a/tests/issue_85.py +++ b/tests/issue_85.py @@ -26,6 +26,7 @@ def init_django(): os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') django.setup() from django.core.cache import cache + shard = cache._cache._shards[0] diff --git a/tests/models.py b/tests/models.py index 349fd87..a546822 100644 --- a/tests/models.py +++ b/tests/models.py @@ -10,4 +10,6 @@ def expensive_calculation(): class Poll(models.Model): question = models.CharField(max_length=200) answer = models.CharField(max_length=200) - pub_date = models.DateTimeField('date published', default=expensive_calculation) + pub_date = models.DateTimeField( + 'date published', default=expensive_calculation + ) diff --git a/tests/plot.py b/tests/plot.py index d8d1be0..2f83f14 100644 --- a/tests/plot.py +++ b/tests/plot.py @@ -93,12 +93,17 @@ def make_plot(data, action, save=False, show=False, limit=0.005): bars = [] for pos, (name, color) in enumerate(zip(names, colors)): - bars.append(ax.bar( - [val + pos * width for val in index], - [parse_timing(data[name][action][tick], limit) for tick in ticks], - width, - color=color, - )) + bars.append( + ax.bar( + [val + pos * width for val in index], + [ + parse_timing(data[name][action][tick], limit) + for tick in ticks + ], + width, + color=color, + ) + ) ax.set_ylabel('Time (microseconds)') ax.set_title('"%s" Time vs Percentile' % action) @@ -106,12 +111,14 @@ def make_plot(data, action, save=False, show=False, limit=0.005): ax.set_xticklabels(ticks) box = ax.get_position() - ax.set_position([box.x0, box.y0 + box.height * 0.2, box.width, box.height * 0.8]) + ax.set_position( + [box.x0, box.y0 + box.height * 0.2, box.width, box.height * 0.8] + ) ax.legend( [bar[0] for bar in bars], names, loc='lower center', - bbox_to_anchor=(0.5, -0.25) + bbox_to_anchor=(0.5, -0.25), ) if show: diff --git a/tests/plot_early_recompute.py b/tests/plot_early_recompute.py index bbebc1a..da46e3e 100644 --- a/tests/plot_early_recompute.py +++ b/tests/plot_early_recompute.py @@ -16,6 +16,7 @@ def make_timer(times): """ lock = threading.Lock() + def timer(func): @ft.wraps(func) def wrapper(*args, **kwargs): @@ -24,7 +25,9 @@ def wrapper(*args, **kwargs): pair = start, time.time() with lock: times.append(pair) + return wrapper + return timer @@ -33,9 +36,11 @@ def make_worker(times, delay=0.2): `delay` seconds. """ + @make_timer(times) def worker(): time.sleep(delay) + return worker @@ -44,11 +49,13 @@ def make_repeater(func, total=10, delay=0.01): repeatedly until `total` seconds have elapsed. """ + def repeat(num): start = time.time() while time.time() - start < total: func() time.sleep(delay) + return repeat @@ -62,6 +69,7 @@ def frange(start, stop, step=1e-3): def plot(option, filename, cache_times, worker_times): "Plot concurrent workers and latency." import matplotlib.pyplot as plt + fig, (workers, latency) = plt.subplots(2, sharex=True) fig.suptitle(option) diff --git a/tests/settings_benchmark.py b/tests/settings_benchmark.py index 5b614a5..1724cef 100644 --- a/tests/settings_benchmark.py +++ b/tests/settings_benchmark.py @@ -14,7 +14,7 @@ 'LOCATION': 'redis://127.0.0.1:6379/1', 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - } + }, }, 'filebased': { 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', @@ -22,7 +22,7 @@ 'OPTIONS': { 'CULL_FREQUENCY': 10, 'MAX_ENTRIES': 1000, - } + }, }, 'locmem': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', @@ -30,7 +30,7 @@ 'OPTIONS': { 'CULL_FREQUENCY': 10, 'MAX_ENTRIES': 1000, - } + }, }, 'diskcache': { 'BACKEND': 'diskcache.DjangoCache', diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index 68f2ebd..a36ae4e 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -33,13 +33,17 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)) + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ) size = random.randint(1, int(200 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)).encode('utf-8') + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ).encode('utf-8') size = random.randint(1, int(200 / 13)) return word * size @@ -49,7 +53,14 @@ def make_float(): def make_object(): return (make_float(),) * random.randint(1, 20) - funcs = [make_int, make_long, make_unicode, make_bytes, make_float, make_object] + funcs = [ + make_int, + make_long, + make_unicode, + make_bytes, + make_float, + make_object, + ] while True: func = random.choice(funcs) @@ -66,13 +77,17 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)) + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ) size = random.randint(1, int(2 ** 16 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)).encode('utf-8') + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ).encode('utf-8') size = random.randint(1, int(2 ** 16 / 13)) return word * size @@ -82,7 +97,14 @@ def make_float(): def make_object(): return [make_float()] * random.randint(1, int(2e3)) - funcs = [make_int, make_long, make_unicode, make_bytes, make_float, make_object] + funcs = [ + make_int, + make_long, + make_unicode, + make_bytes, + make_float, + make_object, + ] while True: func = random.choice(funcs) @@ -134,7 +156,12 @@ def worker(queue, eviction_policy, processes, threads): stop = time.time() - if action == 'get' and processes == 1 and threads == 1 and EXPIRE is None: + if ( + action == 'get' + and processes == 1 + and threads == 1 + and EXPIRE is None + ): assert result == value if index > WARMUP: @@ -155,8 +182,10 @@ def dispatch(num, eviction_policy, processes, threads): thread_queues = [queue.Queue() for _ in range(threads)] subthreads = [ threading.Thread( - target=worker, args=(thread_queue, eviction_policy, processes, threads) - ) for thread_queue in thread_queues + target=worker, + args=(thread_queue, eviction_policy, processes, threads), + ) + for thread_queue in thread_queues ] for index, triplet in enumerate(process_queue): @@ -201,9 +230,13 @@ def percentile(sequence, percent): return values[pos] -def stress_test(create=True, delete=True, - eviction_policy=u'least-recently-stored', - processes=1, threads=1): +def stress_test( + create=True, + delete=True, + eviction_policy=u'least-recently-stored', + processes=1, + threads=1, +): shutil.rmtree('tmp', ignore_errors=True) if processes == 1: @@ -285,51 +318,84 @@ def stress_test_mp(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - '-n', '--operations', type=float, default=OPERATIONS, + '-n', + '--operations', + type=float, + default=OPERATIONS, help='Number of operations to perform', ) parser.add_argument( - '-g', '--get-average', type=float, default=GET_AVERAGE, + '-g', + '--get-average', + type=float, + default=GET_AVERAGE, help='Expected value of exponential variate used for GET count', ) parser.add_argument( - '-k', '--key-count', type=float, default=KEY_COUNT, - help='Number of unique keys' + '-k', + '--key-count', + type=float, + default=KEY_COUNT, + help='Number of unique keys', ) parser.add_argument( - '-d', '--del-chance', type=float, default=DEL_CHANCE, + '-d', + '--del-chance', + type=float, + default=DEL_CHANCE, help='Likelihood of a key deletion', ) parser.add_argument( - '-w', '--warmup', type=float, default=WARMUP, + '-w', + '--warmup', + type=float, + default=WARMUP, help='Number of warmup operations before timings', ) parser.add_argument( - '-e', '--expire', type=float, default=EXPIRE, + '-e', + '--expire', + type=float, + default=EXPIRE, help='Number of seconds before key expires', ) parser.add_argument( - '-t', '--threads', type=int, default=1, + '-t', + '--threads', + type=int, + default=1, help='Number of threads to start in each process', ) parser.add_argument( - '-p', '--processes', type=int, default=1, + '-p', + '--processes', + type=int, + default=1, help='Number of processes to start', ) parser.add_argument( - '-s', '--seed', type=int, default=0, + '-s', + '--seed', + type=int, + default=0, help='Random seed', ) parser.add_argument( - '--no-create', action='store_false', dest='create', + '--no-create', + action='store_false', + dest='create', help='Do not create operations data', ) parser.add_argument( - '--no-delete', action='store_false', dest='delete', + '--no-delete', + action='store_false', + dest='delete', help='Do not delete operations data', ) parser.add_argument( - '-v', '--eviction-policy', type=unicode, + '-v', + '--eviction-policy', + type=unicode, default=u'least-recently-stored', ) diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py index 422e874..fc2de39 100644 --- a/tests/stress_test_fanout.py +++ b/tests/stress_test_fanout.py @@ -33,13 +33,17 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)) + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ) size = random.randint(1, int(200 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)).encode('utf-8') + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ).encode('utf-8') size = random.randint(1, int(200 / 13)) return word * size @@ -49,7 +53,14 @@ def make_float(): def make_object(): return (make_float(),) * random.randint(1, 20) - funcs = [make_int, make_long, make_unicode, make_bytes, make_float, make_object] + funcs = [ + make_int, + make_long, + make_unicode, + make_bytes, + make_float, + make_object, + ] while True: func = random.choice(funcs) @@ -66,13 +77,17 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)) + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ) size = random.randint(1, int(2 ** 16 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join(random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size)).encode('utf-8') + word = u''.join( + random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + ).encode('utf-8') size = random.randint(1, int(2 ** 16 / 13)) return word * size @@ -82,7 +97,14 @@ def make_float(): def make_object(): return [make_float()] * random.randint(1, int(2e3)) - funcs = [make_int, make_long, make_unicode, make_bytes, make_float, make_object] + funcs = [ + make_int, + make_long, + make_unicode, + make_bytes, + make_float, + make_object, + ] while True: func = random.choice(funcs) @@ -129,7 +151,12 @@ def worker(queue, eviction_policy, processes, threads): stop = time.time() - if action == 'get' and processes == 1 and threads == 1 and EXPIRE is None: + if ( + action == 'get' + and processes == 1 + and threads == 1 + and EXPIRE is None + ): assert result == value if index > WARMUP: @@ -147,8 +174,10 @@ def dispatch(num, eviction_policy, processes, threads): thread_queues = [queue.Queue() for _ in range(threads)] subthreads = [ threading.Thread( - target=worker, args=(thread_queue, eviction_policy, processes, threads) - ) for thread_queue in thread_queues + target=worker, + args=(thread_queue, eviction_policy, processes, threads), + ) + for thread_queue in thread_queues ] for index, triplet in enumerate(process_queue): @@ -193,9 +222,13 @@ def percentile(sequence, percent): return values[pos] -def stress_test(create=True, delete=True, - eviction_policy=u'least-recently-stored', - processes=1, threads=1): +def stress_test( + create=True, + delete=True, + eviction_policy=u'least-recently-stored', + processes=1, + threads=1, +): shutil.rmtree('tmp', ignore_errors=True) if processes == 1: @@ -277,51 +310,84 @@ def stress_test_mp(): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - '-n', '--operations', type=float, default=OPERATIONS, + '-n', + '--operations', + type=float, + default=OPERATIONS, help='Number of operations to perform', ) parser.add_argument( - '-g', '--get-average', type=float, default=GET_AVERAGE, + '-g', + '--get-average', + type=float, + default=GET_AVERAGE, help='Expected value of exponential variate used for GET count', ) parser.add_argument( - '-k', '--key-count', type=float, default=KEY_COUNT, - help='Number of unique keys' + '-k', + '--key-count', + type=float, + default=KEY_COUNT, + help='Number of unique keys', ) parser.add_argument( - '-d', '--del-chance', type=float, default=DEL_CHANCE, + '-d', + '--del-chance', + type=float, + default=DEL_CHANCE, help='Likelihood of a key deletion', ) parser.add_argument( - '-w', '--warmup', type=float, default=WARMUP, + '-w', + '--warmup', + type=float, + default=WARMUP, help='Number of warmup operations before timings', ) parser.add_argument( - '-e', '--expire', type=float, default=EXPIRE, + '-e', + '--expire', + type=float, + default=EXPIRE, help='Number of seconds before key expires', ) parser.add_argument( - '-t', '--threads', type=int, default=1, + '-t', + '--threads', + type=int, + default=1, help='Number of threads to start in each process', ) parser.add_argument( - '-p', '--processes', type=int, default=1, + '-p', + '--processes', + type=int, + default=1, help='Number of processes to start', ) parser.add_argument( - '-s', '--seed', type=int, default=0, + '-s', + '--seed', + type=int, + default=0, help='Random seed', ) parser.add_argument( - '--no-create', action='store_false', dest='create', + '--no-create', + action='store_false', + dest='create', help='Do not create operations data', ) parser.add_argument( - '--no-delete', action='store_false', dest='delete', + '--no-delete', + action='store_false', + dest='delete', help='Do not delete operations data', ) parser.add_argument( - '-v', '--eviction-policy', type=unicode, + '-v', + '--eviction-policy', + type=unicode, default=u'least-recently-stored', ) diff --git a/tests/test_core.py b/tests/test_core.py index 31b4034..cb4e34f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -26,6 +26,7 @@ pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning) + @pytest.fixture def cache(): with dc.Cache() as cache: @@ -161,6 +162,7 @@ def test_close_error(cache): class LocalTest(object): def __init__(self): self._calls = 0 + def __getattr__(self, name): if self._calls: raise AttributeError @@ -303,6 +305,7 @@ def test_get(cache): assert cache.get(0, tag=True) == (0, u'number') assert cache.get(0, expire_time=True, tag=True) == (0, None, u'number') + def test_get_expired_fast_path(cache): assert cache.set(0, 0, expire=0.001) time.sleep(0.01) @@ -642,7 +645,7 @@ def test_check(cache): cache.check() cache.check(fix=True) - assert len(cache.check()) == 0 # Should display no warnings. + assert len(cache.check()) == 0 # Should display no warnings. def test_integrity_check(cache): @@ -653,7 +656,7 @@ def test_integrity_check(cache): with io.open(op.join(cache.directory, 'cache.db'), 'r+b') as writer: writer.seek(52) - writer.write(b'\x00\x01') # Should be 0, change it. + writer.write(b'\x00\x01') # Should be 0, change it. cache = dc.Cache(cache.directory) @@ -1262,8 +1265,8 @@ def test_cull_timeout(cache): def test_key_roundtrip(cache): - key_part_0 = u"part0" - key_part_1 = u"part1" + key_part_0 = u'part0' + key_part_1 = u'part1' to_test = [ (key_part_0, key_part_1), [key_part_0, key_part_1], @@ -1281,6 +1284,7 @@ def test_key_roundtrip(cache): def test_constant(): import diskcache.core + assert repr(diskcache.core.ENOVAL) == 'ENOVAL' diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 84d2f95..5cb0047 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -20,28 +20,43 @@ from django.conf import settings from django.core import management, signals from django.core.cache import ( - DEFAULT_CACHE_ALIAS, CacheKeyWarning, InvalidCacheKey, cache, caches, + DEFAULT_CACHE_ALIAS, + CacheKeyWarning, + InvalidCacheKey, + cache, + caches, ) from django.core.cache.utils import make_template_fragment_key from django.db import close_old_connections, connection, connections from django.http import ( - HttpRequest, HttpResponse, HttpResponseNotModified, StreamingHttpResponse, + HttpRequest, + HttpResponse, + HttpResponseNotModified, + StreamingHttpResponse, ) from django.middleware.cache import ( - CacheMiddleware, FetchFromCacheMiddleware, UpdateCacheMiddleware, + CacheMiddleware, + FetchFromCacheMiddleware, + UpdateCacheMiddleware, ) from django.middleware.csrf import CsrfViewMiddleware from django.template import engines from django.template.context_processors import csrf from django.template.response import TemplateResponse from django.test import ( - RequestFactory, SimpleTestCase, TestCase, TransactionTestCase, + RequestFactory, + SimpleTestCase, + TestCase, + TransactionTestCase, override_settings, ) from django.test.signals import setting_changed from django.utils import timezone, translation from django.utils.cache import ( - get_cache_key, learn_cache_key, patch_cache_control, patch_vary_headers, + get_cache_key, + learn_cache_key, + patch_cache_control, + patch_vary_headers, ) from django.utils.encoding import force_text from django.views.decorators.cache import cache_page @@ -53,6 +68,7 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') import django + django.setup() from .models import Poll, expensive_calculation @@ -81,7 +97,7 @@ def __getstate__(self): class UnpicklableType(object): # Unpicklable using the default pickling protocol on Python 2. - __slots__ = 'a', + __slots__ = ('a',) def custom_key_func(key, key_prefix, version): @@ -110,7 +126,9 @@ def caches_setting_for_tests(base=None, exclude=None, **params): # params -> _caches_setting_base -> base base = base or {} exclude = exclude or set() - setting = {k: base.copy() for k in _caches_setting_base if k not in exclude} + setting = { + k: base.copy() for k in _caches_setting_base if k not in exclude + } for key, cache_params in setting.items(): cache_params.update(_caches_setting_base[key]) cache_params.update(params) @@ -126,15 +144,15 @@ def tearDown(self): def test_simple(self): # Simple cache set/get works - cache.set("key", "value") - self.assertEqual(cache.get("key"), "value") + cache.set('key', 'value') + self.assertEqual(cache.get('key'), 'value') def test_add(self): # A key can be added to a cache - cache.add("addkey1", "value") - result = cache.add("addkey1", "newvalue") + cache.add('addkey1', 'value') + result = cache.add('addkey1', 'newvalue') self.assertFalse(result) - self.assertEqual(cache.get("addkey1"), "value") + self.assertEqual(cache.get('addkey1'), 'value') def test_prefix(self): # Test for same cache key conflicts between shared backend @@ -150,37 +168,41 @@ def test_prefix(self): def test_non_existent(self): """Nonexistent cache keys return as None/default.""" - self.assertIsNone(cache.get("does_not_exist")) - self.assertEqual(cache.get("does_not_exist", "bang!"), "bang!") + self.assertIsNone(cache.get('does_not_exist')) + self.assertEqual(cache.get('does_not_exist', 'bang!'), 'bang!') def test_get_many(self): # Multiple cache keys can be returned using get_many cache.set_many({'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd'}) - self.assertEqual(cache.get_many(['a', 'c', 'd']), {'a': 'a', 'c': 'c', 'd': 'd'}) + self.assertEqual( + cache.get_many(['a', 'c', 'd']), {'a': 'a', 'c': 'c', 'd': 'd'} + ) self.assertEqual(cache.get_many(['a', 'b', 'e']), {'a': 'a', 'b': 'b'}) - self.assertEqual(cache.get_many(iter(['a', 'b', 'e'])), {'a': 'a', 'b': 'b'}) + self.assertEqual( + cache.get_many(iter(['a', 'b', 'e'])), {'a': 'a', 'b': 'b'} + ) def test_delete(self): # Cache keys can be deleted cache.set_many({'key1': 'spam', 'key2': 'eggs'}) - self.assertEqual(cache.get("key1"), "spam") - cache.delete("key1") - self.assertIsNone(cache.get("key1")) - self.assertEqual(cache.get("key2"), "eggs") + self.assertEqual(cache.get('key1'), 'spam') + cache.delete('key1') + self.assertIsNone(cache.get('key1')) + self.assertEqual(cache.get('key2'), 'eggs') def test_has_key(self): # The cache can be inspected for cache keys - cache.set("hello1", "goodbye1") - self.assertTrue(cache.has_key("hello1")) - self.assertFalse(cache.has_key("goodbye1")) - cache.set("no_expiry", "here", None) - self.assertTrue(cache.has_key("no_expiry")) + cache.set('hello1', 'goodbye1') + self.assertTrue(cache.has_key('hello1')) + self.assertFalse(cache.has_key('goodbye1')) + cache.set('no_expiry', 'here', None) + self.assertTrue(cache.has_key('no_expiry')) def test_in(self): # The in operator can be used to inspect cache contents - cache.set("hello2", "goodbye2") - self.assertIn("hello2", cache) - self.assertNotIn("goodbye2", cache) + cache.set('hello2', 'goodbye2') + self.assertIn('hello2', cache) + self.assertNotIn('goodbye2', cache) def test_incr(self): # Cache values can be incremented @@ -219,14 +241,14 @@ def test_data_types(self): 'function': f, 'class': C, } - cache.set("stuff", stuff) - self.assertEqual(cache.get("stuff"), stuff) + cache.set('stuff', stuff) + self.assertEqual(cache.get('stuff'), stuff) def test_cache_read_for_model_instance(self): # Don't want fields with callable as default to be called on cache read expensive_calculation.num_runs = 0 Poll.objects.all().delete() - my_poll = Poll.objects.create(question="Well?") + my_poll = Poll.objects.create(question='Well?') self.assertEqual(Poll.objects.count(), 1) pub_date = my_poll.pub_date cache.set('question', my_poll) @@ -239,7 +261,7 @@ def test_cache_write_for_model_instance_with_deferred(self): # Don't want fields with callable as default to be called on cache write expensive_calculation.num_runs = 0 Poll.objects.all().delete() - Poll.objects.create(question="What?") + Poll.objects.create(question='What?') self.assertEqual(expensive_calculation.num_runs, 1) defer_qs = Poll.objects.all().defer('question') self.assertEqual(defer_qs.count(), 1) @@ -252,7 +274,7 @@ def test_cache_read_for_model_instance_with_deferred(self): # Don't want fields with callable as default to be called on cache read expensive_calculation.num_runs = 0 Poll.objects.all().delete() - Poll.objects.create(question="What?") + Poll.objects.create(question='What?') self.assertEqual(expensive_calculation.num_runs, 1) defer_qs = Poll.objects.all().defer('question') self.assertEqual(defer_qs.count(), 1) @@ -261,7 +283,9 @@ def test_cache_read_for_model_instance_with_deferred(self): runs_before_cache_read = expensive_calculation.num_runs cache.get('deferred_queryset') # We only want the default expensive calculation run on creation and set - self.assertEqual(expensive_calculation.num_runs, runs_before_cache_read) + self.assertEqual( + expensive_calculation.num_runs, runs_before_cache_read + ) def test_expiration(self): # Cache values can be set to expire @@ -270,11 +294,11 @@ def test_expiration(self): cache.set('expire3', 'very quickly', 1) time.sleep(2) - self.assertIsNone(cache.get("expire1")) + self.assertIsNone(cache.get('expire1')) - cache.add("expire2", "newvalue") - self.assertEqual(cache.get("expire2"), "newvalue") - self.assertFalse(cache.has_key("expire3")) + cache.add('expire2', 'newvalue') + self.assertEqual(cache.get('expire2'), 'newvalue') + self.assertFalse(cache.has_key('expire3')) def test_touch(self): # cache.touch() updates the timeout. @@ -299,7 +323,7 @@ def test_unicode(self): 'ascii': 'ascii_value', 'unicode_ascii': 'Iñtërnâtiônàlizætiøn1', 'Iñtërnâtiônàlizætiøn': 'Iñtërnâtiônàlizætiøn2', - 'ascii2': {'x': 1} + 'ascii2': {'x': 1}, } # Test `set` for (key, value) in stuff.items(): @@ -325,6 +349,7 @@ def test_unicode(self): def test_binary_string(self): # Binary strings should be cacheable from zlib import compress, decompress + value = 'value_to_be_compressed' compressed_value = compress(value.encode()) @@ -348,9 +373,9 @@ def test_binary_string(self): def test_set_many(self): # Multiple keys can be set using set_many - cache.set_many({"key1": "spam", "key2": "eggs"}) - self.assertEqual(cache.get("key1"), "spam") - self.assertEqual(cache.get("key2"), "eggs") + cache.set_many({'key1': 'spam', 'key2': 'eggs'}) + self.assertEqual(cache.get('key1'), 'spam') + self.assertEqual(cache.get('key2'), 'eggs') def test_set_many_returns_empty_list_on_success(self): """set_many() returns an empty list when all keys are inserted.""" @@ -359,25 +384,25 @@ def test_set_many_returns_empty_list_on_success(self): def test_set_many_expiration(self): # set_many takes a second ``timeout`` parameter - cache.set_many({"key1": "spam", "key2": "eggs"}, 1) + cache.set_many({'key1': 'spam', 'key2': 'eggs'}, 1) time.sleep(2) - self.assertIsNone(cache.get("key1")) - self.assertIsNone(cache.get("key2")) + self.assertIsNone(cache.get('key1')) + self.assertIsNone(cache.get('key2')) def test_delete_many(self): # Multiple keys can be deleted using delete_many cache.set_many({'key1': 'spam', 'key2': 'eggs', 'key3': 'ham'}) - cache.delete_many(["key1", "key2"]) - self.assertIsNone(cache.get("key1")) - self.assertIsNone(cache.get("key2")) - self.assertEqual(cache.get("key3"), "ham") + cache.delete_many(['key1', 'key2']) + self.assertIsNone(cache.get('key1')) + self.assertIsNone(cache.get('key2')) + self.assertEqual(cache.get('key3'), 'ham') def test_clear(self): # The cache can be emptied using clear cache.set_many({'key1': 'spam', 'key2': 'eggs'}) cache.clear() - self.assertIsNone(cache.get("key1")) - self.assertIsNone(cache.get("key2")) + self.assertIsNone(cache.get('key1')) + self.assertIsNone(cache.get('key2')) def test_long_timeout(self): """ @@ -391,7 +416,10 @@ def test_long_timeout(self): cache.add('key2', 'ham', 60 * 60 * 24 * 30 + 1) self.assertEqual(cache.get('key2'), 'ham') - cache.set_many({'key3': 'sausage', 'key4': 'lobster bisque'}, 60 * 60 * 24 * 30 + 1) + cache.set_many( + {'key3': 'sausage', 'key4': 'lobster bisque'}, + 60 * 60 * 24 * 30 + 1, + ) self.assertEqual(cache.get('key3'), 'sausage') self.assertEqual(cache.get('key4'), 'lobster bisque') @@ -437,8 +465,8 @@ def test_zero_timeout(self): def test_float_timeout(self): # Make sure a timeout given as a float doesn't crash anything. - cache.set("key1", "spam", 100.2) - self.assertEqual(cache.get("key1"), "spam") + cache.set('key1', 'spam', 100.2) + self.assertEqual(cache.get('key1'), 'spam') def _perform_cull_test(self, cull_cache, initial_count, final_count): # Create initial cache key entries. This will overflow the cache, @@ -483,7 +511,9 @@ def func(key, *args): def test_invalid_key_characters(self): # memcached doesn't allow whitespace or control characters in keys. key = 'key with spaces and 清' - self._perform_invalid_key_test(key, KEY_ERRORS_WITH_MEMCACHED_MSG % key) + self._perform_invalid_key_test( + key, KEY_ERRORS_WITH_MEMCACHED_MSG % key + ) def test_invalid_key_length(self): # memcached limits key length to 250. @@ -653,43 +683,85 @@ def test_cache_versioning_incr_decr(self): def test_cache_versioning_get_set_many(self): # set, using default version = 1 cache.set_many({'ford1': 37, 'arthur1': 42}) - self.assertEqual(cache.get_many(['ford1', 'arthur1']), {'ford1': 37, 'arthur1': 42}) - self.assertEqual(cache.get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) + self.assertEqual( + cache.get_many(['ford1', 'arthur1']), {'ford1': 37, 'arthur1': 42} + ) + self.assertEqual( + cache.get_many(['ford1', 'arthur1'], version=1), + {'ford1': 37, 'arthur1': 42}, + ) self.assertEqual(cache.get_many(['ford1', 'arthur1'], version=2), {}) self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1']), {}) - self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=1), {'ford1': 37, 'arthur1': 42}) - self.assertEqual(caches['v2'].get_many(['ford1', 'arthur1'], version=2), {}) + self.assertEqual( + caches['v2'].get_many(['ford1', 'arthur1'], version=1), + {'ford1': 37, 'arthur1': 42}, + ) + self.assertEqual( + caches['v2'].get_many(['ford1', 'arthur1'], version=2), {} + ) # set, default version = 1, but manually override version = 2 cache.set_many({'ford2': 37, 'arthur2': 42}, version=2) self.assertEqual(cache.get_many(['ford2', 'arthur2']), {}) self.assertEqual(cache.get_many(['ford2', 'arthur2'], version=1), {}) - self.assertEqual(cache.get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) + self.assertEqual( + cache.get_many(['ford2', 'arthur2'], version=2), + {'ford2': 37, 'arthur2': 42}, + ) - self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2']), {'ford2': 37, 'arthur2': 42}) - self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=1), {}) - self.assertEqual(caches['v2'].get_many(['ford2', 'arthur2'], version=2), {'ford2': 37, 'arthur2': 42}) + self.assertEqual( + caches['v2'].get_many(['ford2', 'arthur2']), + {'ford2': 37, 'arthur2': 42}, + ) + self.assertEqual( + caches['v2'].get_many(['ford2', 'arthur2'], version=1), {} + ) + self.assertEqual( + caches['v2'].get_many(['ford2', 'arthur2'], version=2), + {'ford2': 37, 'arthur2': 42}, + ) # v2 set, using default version = 2 caches['v2'].set_many({'ford3': 37, 'arthur3': 42}) self.assertEqual(cache.get_many(['ford3', 'arthur3']), {}) self.assertEqual(cache.get_many(['ford3', 'arthur3'], version=1), {}) - self.assertEqual(cache.get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) + self.assertEqual( + cache.get_many(['ford3', 'arthur3'], version=2), + {'ford3': 37, 'arthur3': 42}, + ) - self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3']), {'ford3': 37, 'arthur3': 42}) - self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=1), {}) - self.assertEqual(caches['v2'].get_many(['ford3', 'arthur3'], version=2), {'ford3': 37, 'arthur3': 42}) + self.assertEqual( + caches['v2'].get_many(['ford3', 'arthur3']), + {'ford3': 37, 'arthur3': 42}, + ) + self.assertEqual( + caches['v2'].get_many(['ford3', 'arthur3'], version=1), {} + ) + self.assertEqual( + caches['v2'].get_many(['ford3', 'arthur3'], version=2), + {'ford3': 37, 'arthur3': 42}, + ) # v2 set, default version = 2, but manually override version = 1 caches['v2'].set_many({'ford4': 37, 'arthur4': 42}, version=1) - self.assertEqual(cache.get_many(['ford4', 'arthur4']), {'ford4': 37, 'arthur4': 42}) - self.assertEqual(cache.get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) + self.assertEqual( + cache.get_many(['ford4', 'arthur4']), {'ford4': 37, 'arthur4': 42} + ) + self.assertEqual( + cache.get_many(['ford4', 'arthur4'], version=1), + {'ford4': 37, 'arthur4': 42}, + ) self.assertEqual(cache.get_many(['ford4', 'arthur4'], version=2), {}) self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4']), {}) - self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=1), {'ford4': 37, 'arthur4': 42}) - self.assertEqual(caches['v2'].get_many(['ford4', 'arthur4'], version=2), {}) + self.assertEqual( + caches['v2'].get_many(['ford4', 'arthur4'], version=1), + {'ford4': 37, 'arthur4': 42}, + ) + self.assertEqual( + caches['v2'].get_many(['ford4', 'arthur4'], version=2), {} + ) def test_incr_version(self): cache.set('answer', 42, version=2) @@ -825,7 +897,9 @@ def test_get_or_set_version(self): self.assertIsNone(cache.get('brian', version=3)) def test_get_or_set_racing(self): - with mock.patch('%s.%s' % (settings.CACHES['default']['BACKEND'], 'add')) as cache_add: + with mock.patch( + '%s.%s' % (settings.CACHES['default']['BACKEND'], 'add') + ) as cache_add: # Simulate cache.add() failing to add a value. In that case, the # default value should be returned. cache_add.return_value = False @@ -833,7 +907,6 @@ def test_get_or_set_racing(self): class PicklingSideEffect: - def __init__(self, cache): self.cache = cache self.locked = False @@ -843,11 +916,14 @@ def __getstate__(self): return {} -@override_settings(CACHES=caches_setting_for_tests( - BACKEND='diskcache.DjangoCache', -)) +@override_settings( + CACHES=caches_setting_for_tests( + BACKEND='diskcache.DjangoCache', + ) +) class DiskCacheTests(BaseCacheTests, TestCase): "Specific test cases for diskcache.DjangoCache." + def setUp(self): super().setUp() self.dirname = tempfile.mkdtemp() @@ -868,14 +944,18 @@ def test_ignores_non_cache_files(self): with open(fname, 'w'): os.utime(fname, None) cache.clear() - self.assertTrue(os.path.exists(fname), - 'Expected cache.clear to ignore non cache files') + self.assertTrue( + os.path.exists(fname), + 'Expected cache.clear to ignore non cache files', + ) os.remove(fname) def test_clear_does_not_remove_cache_dir(self): cache.clear() - self.assertTrue(os.path.exists(self.dirname), - 'Expected cache.clear to keep the cache dir') + self.assertTrue( + os.path.exists(self.dirname), + 'Expected cache.clear to keep the cache dir', + ) def test_cache_write_unpicklable_type(self): # This fails if not using the highest pickling protocol on Python 2. @@ -942,7 +1022,9 @@ def test_pop(self): self.assertEqual(cache.pop(0, default=1), 1) self.assertEqual(cache.pop(1, expire_time=True), (1, None)) self.assertEqual(cache.pop(2, tag=True), (2, None)) - self.assertEqual(cache.pop(3, expire_time=True, tag=True), (3, None, None)) + self.assertEqual( + cache.pop(3, expire_time=True, tag=True), (3, None, None) + ) self.assertEqual(cache.pop(4, retry=False), 4) def test_pickle(self): @@ -975,6 +1057,7 @@ def test_index(self): def test_memoize(self): with self.assertRaises(TypeError): + @cache.memoize # <-- Missing parens! def test(): pass diff --git a/tests/test_recipes.py b/tests/test_recipes.py index b26a239..2d53d49 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -31,11 +31,13 @@ def test_averager(cache): def test_rlock(cache): state = {'num': 0} rlock = dc.RLock(cache, 'demo') + def worker(): state['num'] += 1 with rlock: state['num'] += 1 time.sleep(0.1) + with rlock: thread = threading.Thread(target=worker) thread.start() @@ -48,11 +50,13 @@ def worker(): def test_semaphore(cache): state = {'num': 0} semaphore = dc.BoundedSemaphore(cache, 'demo', value=3) + def worker(): state['num'] += 1 with semaphore: state['num'] += 1 time.sleep(0.1) + semaphore.acquire() semaphore.acquire() with semaphore: @@ -68,11 +72,13 @@ def worker(): def test_memoize_stampede(cache): state = {'num': 0} + @dc.memoize_stampede(cache, 0.1) def worker(num): time.sleep(0.01) state['num'] += 1 return num + start = time.time() while (time.time() - start) < 1: worker(100) diff --git a/tests/utils.py b/tests/utils.py index 47da791..504b930 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -96,16 +96,19 @@ def display(name, timings): len_total += len(values) sum_total += sum(values) - print(template % ( - action, - len(values), - len(timings.get(action + '-miss', [])), - secs(percentile(values, 0.5)), - secs(percentile(values, 0.9)), - secs(percentile(values, 0.99)), - secs(percentile(values, 1.0)), - secs(sum(values)), - )) + print( + template + % ( + action, + len(values), + len(timings.get(action + '-miss', [])), + secs(percentile(values, 0.5)), + secs(percentile(values, 0.9)), + secs(percentile(values, 0.99)), + secs(percentile(values, 1.0)), + secs(sum(values)), + ) + ) totals = ('Total', len_total, '', '', '', '', '', secs(sum_total)) print(template % totals) From 3b87dde40b4d0b2a7f96534110b5103ce7c9e1fc Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:27:24 -0800 Subject: [PATCH 232/329] Make imports consistent with isort --- diskcache/__init__.py | 18 ++++++++++++++---- diskcache/djangocache.py | 1 + diskcache/fanout.py | 2 +- diskcache/persistent.py | 12 ++++++++---- setup.py | 1 + tests/benchmark_kv_store.py | 4 ++-- tests/issue_109.py | 1 + tests/issue_85.py | 3 ++- tests/plot.py | 3 ++- tests/plot_early_recompute.py | 3 ++- tests/stress_test_core.py | 3 ++- tests/stress_test_fanout.py | 3 ++- tests/test_core.py | 4 ++-- tests/test_deque.py | 4 ++-- tests/test_djangocache.py | 1 - tests/test_fanout.py | 4 ++-- tests/test_index.py | 4 ++-- tests/test_recipes.py | 7 ++++--- 18 files changed, 50 insertions(+), 28 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 0300123..befed76 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -7,18 +7,28 @@ """ from .core import ( + DEFAULT_SETTINGS, + ENOVAL, + EVICTION_POLICY, + UNKNOWN, Cache, Disk, EmptyDirWarning, JSONDisk, - UnknownFileWarning, Timeout, + UnknownFileWarning, ) -from .core import DEFAULT_SETTINGS, ENOVAL, EVICTION_POLICY, UNKNOWN from .fanout import FanoutCache from .persistent import Deque, Index -from .recipes import Averager, BoundedSemaphore, Lock, RLock -from .recipes import barrier, memoize_stampede, throttle +from .recipes import ( + Averager, + BoundedSemaphore, + Lock, + RLock, + barrier, + memoize_stampede, + throttle, +) __all__ = [ 'Averager', diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 2f1db07..44f673d 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -1,6 +1,7 @@ "Django-compatible disk and file backed cache." from functools import wraps + from django.core.cache.backends.base import BaseCache try: diff --git a/diskcache/fanout.py b/diskcache/fanout.py index cc40c07..e7987b4 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -9,7 +9,7 @@ import tempfile import time -from .core import ENOVAL, DEFAULT_SETTINGS, Cache, Disk, Timeout +from .core import DEFAULT_SETTINGS, ENOVAL, Cache, Disk, Timeout from .persistent import Deque, Index diff --git a/diskcache/persistent.py b/diskcache/persistent.py index ac5a5b0..5ff1939 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -3,14 +3,18 @@ """ import operator as op - from collections import OrderedDict -from collections.abc import MutableMapping, Sequence -from collections.abc import KeysView, ValuesView, ItemsView +from collections.abc import ( + ItemsView, + KeysView, + MutableMapping, + Sequence, + ValuesView, +) from contextlib import contextmanager from shutil import rmtree -from .core import Cache, ENOVAL +from .core import ENOVAL, Cache def _make_compare(seq_op, doc): diff --git a/setup.py b/setup.py index 2d6daa3..5f3cd88 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ from io import open + from setuptools import setup from setuptools.command.test import test as TestCommand diff --git a/tests/benchmark_kv_store.py b/tests/benchmark_kv_store.py index e214f1a..e141a36 100644 --- a/tests/benchmark_kv_store.py +++ b/tests/benchmark_kv_store.py @@ -4,10 +4,10 @@ """ -import diskcache - from IPython import get_ipython +import diskcache + ipython = get_ipython() assert ipython is not None, 'No IPython! Run with $ ipython ...' diff --git a/tests/issue_109.py b/tests/issue_109.py index a5355b1..c10a81f 100644 --- a/tests/issue_109.py +++ b/tests/issue_109.py @@ -3,6 +3,7 @@ """ import time + import diskcache as dc diff --git a/tests/issue_85.py b/tests/issue_85.py index db32046..b325df0 100644 --- a/tests/issue_85.py +++ b/tests/issue_85.py @@ -6,7 +6,6 @@ """ import collections -import django import os import random import shutil @@ -14,6 +13,8 @@ import threading import time +import django + def remove_cache_dir(): print('REMOVING CACHE DIRECTORY') diff --git a/tests/plot.py b/tests/plot.py index 2f83f14..670bf49 100644 --- a/tests/plot.py +++ b/tests/plot.py @@ -7,10 +7,11 @@ import argparse import collections as co -import matplotlib.pyplot as plt import re import sys +import matplotlib.pyplot as plt + def parse_timing(timing, limit): "Parse timing." diff --git a/tests/plot_early_recompute.py b/tests/plot_early_recompute.py index da46e3e..7096ea0 100644 --- a/tests/plot_early_recompute.py +++ b/tests/plot_early_recompute.py @@ -2,13 +2,14 @@ """ -import diskcache as dc import functools as ft import multiprocessing.pool import shutil import threading import time +import diskcache as dc + def make_timer(times): """Make a decorator which accumulates (start, end) in `times` for function diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index a36ae4e..6c48d5c 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -1,7 +1,6 @@ "Stress test diskcache.core.Cache." import collections as co -from diskcache import Cache, UnknownFileWarning, EmptyDirWarning, Timeout import multiprocessing as mp import os import pickle @@ -13,6 +12,8 @@ import time import warnings +from diskcache import Cache, EmptyDirWarning, Timeout, UnknownFileWarning + from .utils import display OPERATIONS = int(1e4) diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py index fc2de39..1f29987 100644 --- a/tests/stress_test_fanout.py +++ b/tests/stress_test_fanout.py @@ -1,7 +1,6 @@ "Stress test diskcache.core.Cache." import collections as co -from diskcache import FanoutCache, UnknownFileWarning, EmptyDirWarning import multiprocessing as mp import os import pickle @@ -13,6 +12,8 @@ import time import warnings +from diskcache import EmptyDirWarning, FanoutCache, UnknownFileWarning + from .utils import display OPERATIONS = int(1e4) diff --git a/tests/test_core.py b/tests/test_core.py index cb4e34f..6fe50c1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,7 +8,6 @@ import os import os.path as op import pickle -import pytest import random import shutil import sqlite3 @@ -19,9 +18,10 @@ import time import unittest import warnings - from unittest import mock +import pytest + import diskcache as dc pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning) diff --git a/tests/test_deque.py b/tests/test_deque.py index 1ca966d..bfb9e7d 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -2,12 +2,12 @@ import functools as ft import pickle -import pytest import shutil import tempfile - from unittest import mock +import pytest + import diskcache as dc from diskcache.core import ENOVAL diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 5cb0047..2c216fe 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -14,7 +14,6 @@ import time import unittest import warnings - from unittest import mock from django.conf import settings diff --git a/tests/test_fanout.py b/tests/test_fanout.py index aa3c833..effe47d 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -8,7 +8,6 @@ import os import os.path as op import pickle -import pytest import random import shutil import sqlite3 @@ -18,9 +17,10 @@ import threading import time import warnings - from unittest import mock +import pytest + import diskcache as dc warnings.simplefilter('error') diff --git a/tests/test_index.py b/tests/test_index.py index 575558c..8852e99 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -2,13 +2,13 @@ import functools as ft import pickle -import pytest import shutil import sys import tempfile - from unittest import mock +import pytest + import diskcache as dc diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 2d53d49..efaa47c 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -1,13 +1,14 @@ "Test diskcache.recipes." -import diskcache as dc -import pytest import shutil import threading import time - from unittest import mock +import pytest + +import diskcache as dc + @pytest.fixture def cache(): From c40b57facd5d9d167a645cb55a4b69257ef20299 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:50:33 -0800 Subject: [PATCH 233/329] Increase max attributes to 8 --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 3314897..8458ed4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -541,7 +541,7 @@ valid-metaclass-classmethod-first-arg=cls max-args=8 # Maximum number of attributes for a class (see R0902). -max-attributes=7 +max-attributes=8 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 From 58227d18dbdd885fd27ba74a9a34e74fb72222d3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:50:46 -0800 Subject: [PATCH 234/329] Tell mypy to ignore django --- mypy.ini | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..053b283 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] + +[mypy-django.*] +ignore_missing_imports = True From db76f111e43ebacc392443b861565bfed14e87b7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:50:58 -0800 Subject: [PATCH 235/329] Remove useless `dataset` target --- README.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/README.rst b/README.rst index 847b2cf..ba2bf3e 100644 --- a/README.rst +++ b/README.rst @@ -314,7 +314,6 @@ Object Relational Mappings (ORM) .. _`Django ORM`: https://docs.djangoproject.com/en/dev/topics/db/ .. _`SQLAlchemy`: https://www.sqlalchemy.org/ .. _`Peewee`: http://docs.peewee-orm.com/ -.. _`dataset`: https://dataset.readthedocs.io/ .. _`SQLObject`: http://sqlobject.org/ .. _`Pony ORM`: https://ponyorm.com/ From b2c0ae421ccdf332698431f1dd29eb6e9936d375 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:51:57 -0800 Subject: [PATCH 236/329] Ignore type errors when setting class attributes --- diskcache/fanout.py | 2 +- diskcache/persistent.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index e7987b4..6a13d6e 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -664,4 +664,4 @@ def index(self, name): return index -FanoutCache.memoize = Cache.memoize +FanoutCache.memoize = Cache.memoize # type: ignore diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 5ff1939..44f9cc7 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -591,7 +591,7 @@ def rotate(self, steps=1): else: self.append(value) - __hash__ = None + __hash__ = None # type: ignore @contextmanager def transact(self): @@ -1033,7 +1033,7 @@ def items(self): """ return ItemsView(self) - __hash__ = None + __hash__ = None # type: ignore def __getstate__(self): return self.directory From 585f47f1084581e8d2fa0aed8ce0dd8a2e0a9162 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:52:58 -0800 Subject: [PATCH 237/329] Add django to deps for sphinx and pylint --- tox.ini | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 6bbe69c..356a576 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,9 @@ commands=doc8 docs allowlist_externals=make changedir=docs commands=make html -deps=sphinx +deps= + django==2.2.* + sphinx [testenv:flake8] commands=flake8 {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests @@ -48,7 +50,9 @@ deps=mypy [testenv:pylint] commands=pylint {toxinidir}/diskcache -deps=pylint +deps= + django==2.2.* + pylint [testenv:rstcheck] commands=rstcheck {toxinidir}/README.rst From 945ee10ca54871032196a0b468ce17d8e0711dcb Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:53:14 -0800 Subject: [PATCH 238/329] Tell doc8 to ignore docs/_build dir --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 356a576..de47de5 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ deps=blue [testenv:doc8] deps=doc8 -commands=doc8 docs +commands=doc8 docs --ignore-path docs/_build [testenv:docs] allowlist_externals=make From 4bbe73fb251830a2b2e7d78c21a67f2ab9b18804 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:53:36 -0800 Subject: [PATCH 239/329] Update flake8 configs --- tox.ini | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index de47de5..d5f43c0 100644 --- a/tox.ini +++ b/tox.ini @@ -91,7 +91,6 @@ addopts= # ignore=D000 [flake8] -# ignore= -# E124 -# E303 -# W503 +exclude=tests/test_djangocache.py +extend-ignore=E203 +max-line-length=120 From 99f0ca2439f6c98ded51d555658b7d538f09010c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 11:53:46 -0800 Subject: [PATCH 240/329] Flake8 fixes (mostly removing useless module imports) --- tests/benchmark_core.py | 9 ++++----- tests/benchmark_djangocache.py | 10 ++++------ tests/issue_85.py | 2 +- tests/plot.py | 2 +- tests/plot_early_recompute.py | 2 +- tests/settings_benchmark.py | 4 ++-- tests/stress_test_core.py | 7 +++---- tests/stress_test_deque.py | 1 - tests/stress_test_deque_mp.py | 1 - tests/stress_test_fanout.py | 4 +--- tests/test_core.py | 9 ++------- tests/test_deque.py | 5 ----- tests/test_doctest.py | 2 -- tests/test_fanout.py | 9 ++------- tests/test_index.py | 3 --- tests/test_recipes.py | 1 - tests/utils.py | 19 ------------------- 17 files changed, 21 insertions(+), 69 deletions(-) diff --git a/tests/benchmark_core.py b/tests/benchmark_core.py index a048b79..282ce2a 100644 --- a/tests/benchmark_core.py +++ b/tests/benchmark_core.py @@ -12,7 +12,6 @@ import pickle import random import shutil -import sys import time import warnings @@ -30,7 +29,7 @@ # Disk Cache Benchmarks ############################################################################### -import diskcache +import diskcache # noqa caches.append( ( @@ -123,13 +122,13 @@ def worker(num, kind, args, kwargs): start = time.time() result = obj.set(key, value) end = time.time() - miss = result == False + miss = result is False action = 'set' else: start = time.time() result = obj.delete(key) end = time.time() - miss = result == False + miss = result is False action = 'delete' if count > WARMUP: @@ -154,7 +153,7 @@ def dispatch(): try: obj.close() - except: + except Exception: pass processes = [ diff --git a/tests/benchmark_djangocache.py b/tests/benchmark_djangocache.py index 0de0424..9dbbcd7 100644 --- a/tests/benchmark_djangocache.py +++ b/tests/benchmark_djangocache.py @@ -12,9 +12,7 @@ import pickle import random import shutil -import sys import time -import warnings from utils import display @@ -59,13 +57,13 @@ def worker(num, name): start = time.time() result = obj.set(key, value) end = time.time() - miss = result == False + miss = result is False action = 'set' else: start = time.time() result = obj.delete(key) end = time.time() - miss = result == False + miss = result is False action = 'delete' if count > WARMUP: @@ -91,14 +89,14 @@ def prepare(name): try: obj.close() - except: + except Exception: pass def dispatch(): setup() - from django.core.cache import caches + from django.core.cache import caches # noqa for name in ['locmem', 'memcached', 'redis', 'diskcache', 'filebased']: shutil.rmtree('tmp', ignore_errors=True) diff --git a/tests/issue_85.py b/tests/issue_85.py index b325df0..723406b 100644 --- a/tests/issue_85.py +++ b/tests/issue_85.py @@ -109,7 +109,7 @@ def run(statements): shard._sql(statement) if index == 0: values.append(('BEGIN', ident)) - except sqlite3.OperationalError as exc: + except sqlite3.OperationalError: values.append(('ERROR', ident)) diff --git a/tests/plot.py b/tests/plot.py index 670bf49..2138659 100644 --- a/tests/plot.py +++ b/tests/plot.py @@ -48,7 +48,7 @@ def parse_data(infile): if blocks.match(line): try: name = title.match(lines[index + 1]).group(1) - except: + except Exception: index += 1 continue diff --git a/tests/plot_early_recompute.py b/tests/plot_early_recompute.py index 7096ea0..e58f580 100644 --- a/tests/plot_early_recompute.py +++ b/tests/plot_early_recompute.py @@ -22,7 +22,7 @@ def timer(func): @ft.wraps(func) def wrapper(*args, **kwargs): start = time.time() - result = func(*args, **kwargs) + func(*args, **kwargs) pair = start, time.time() with lock: times.append(pair) diff --git a/tests/settings_benchmark.py b/tests/settings_benchmark.py index 1724cef..c734e68 100644 --- a/tests/settings_benchmark.py +++ b/tests/settings_benchmark.py @@ -1,9 +1,9 @@ -from .settings import * +from .settings import * # noqa CACHES = { 'default': { 'BACKEND': 'diskcache.DjangoCache', - 'LOCATION': CACHE_DIR, + 'LOCATION': CACHE_DIR, # noqa }, 'memcached': { 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index 6c48d5c..6fd0991 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -7,7 +7,6 @@ import queue import random import shutil -import sys import threading import time import warnings @@ -196,7 +195,7 @@ def dispatch(num, eviction_policy, processes, threads): for thread_queue in thread_queues: thread_queue.put(None) - start = time.time() + # start = time.time() for thread in subthreads: thread.start() @@ -204,7 +203,7 @@ def dispatch(num, eviction_policy, processes, threads): for thread in subthreads: thread.join() - stop = time.time() + # stop = time.time() timings = co.defaultdict(list) @@ -396,7 +395,7 @@ def stress_test_mp(): parser.add_argument( '-v', '--eviction-policy', - type=unicode, + type=str, default=u'least-recently-stored', ) diff --git a/tests/stress_test_deque.py b/tests/stress_test_deque.py index 7b3ac2f..845b2c2 100644 --- a/tests/stress_test_deque.py +++ b/tests/stress_test_deque.py @@ -2,7 +2,6 @@ import collections as co import functools as ft -import itertools as it import random import diskcache as dc diff --git a/tests/stress_test_deque_mp.py b/tests/stress_test_deque_mp.py index db7b5c4..091ff0e 100644 --- a/tests/stress_test_deque_mp.py +++ b/tests/stress_test_deque_mp.py @@ -1,6 +1,5 @@ """Stress test diskcache.persistent.Deque.""" -import functools as ft import itertools as it import multiprocessing as mp import os diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py index 1f29987..58708c9 100644 --- a/tests/stress_test_fanout.py +++ b/tests/stress_test_fanout.py @@ -1,13 +1,11 @@ "Stress test diskcache.core.Cache." -import collections as co import multiprocessing as mp import os import pickle import queue import random import shutil -import sys import threading import time import warnings @@ -388,7 +386,7 @@ def stress_test_mp(): parser.add_argument( '-v', '--eviction-policy', - type=unicode, + type=str, default=u'least-recently-stored', ) diff --git a/tests/test_core.py b/tests/test_core.py index 6fe50c1..bcc79e1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,22 +1,17 @@ "Test diskcache.core.Cache." -import collections as co import errno -import functools as ft import hashlib import io import os import os.path as op import pickle -import random import shutil import sqlite3 import subprocess as sp -import sys import tempfile import threading import time -import unittest import warnings from unittest import mock @@ -130,7 +125,7 @@ def test_init_makedirs(): with pytest.raises(EnvironmentError): try: with mock.patch('os.makedirs', makedirs): - cache = dc.Cache(cache_dir) + dc.Cache(cache_dir) except EnvironmentError: shutil.rmtree(cache_dir, ignore_errors=True) raise @@ -244,7 +239,7 @@ def test_read(cache): def test_read_keyerror(cache): with pytest.raises(KeyError): - with cache.read(0) as reader: + with cache.read(0): pass diff --git a/tests/test_deque.py b/tests/test_deque.py index bfb9e7d..8113dfe 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -1,6 +1,5 @@ "Test diskcache.persistent.Deque." -import functools as ft import pickle import shutil import tempfile @@ -278,7 +277,3 @@ def test_rotate_indexerror_negative(deque): with mock.patch.object(deque, '_cache', cache): deque.rotate(-1) - - -def test_repr(deque): - assert repr(deque).startswith('Deque(') diff --git a/tests/test_doctest.py b/tests/test_doctest.py index 70fa61c..822d8db 100644 --- a/tests/test_doctest.py +++ b/tests/test_doctest.py @@ -1,6 +1,4 @@ import doctest -import shutil -import sys import diskcache.core import diskcache.djangocache diff --git a/tests/test_fanout.py b/tests/test_fanout.py index effe47d..8918af3 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -1,18 +1,13 @@ "Test diskcache.fanout.FanoutCache." import collections as co -import errno -import functools as ft import hashlib import io import os import os.path as op import pickle -import random import shutil -import sqlite3 import subprocess as sp -import sys import tempfile import threading import time @@ -67,7 +62,7 @@ def test_set_get_delete(cache): for value in range(100): assert cache.delete(value) - assert cache.delete(100) == False + assert cache.delete(100) is False cache.check() @@ -353,7 +348,7 @@ def test_read(cache): def test_read_keyerror(cache): with pytest.raises(KeyError): - with cache.read(0) as reader: + with cache.read(0): pass diff --git a/tests/test_index.py b/tests/test_index.py index 8852e99..27639f7 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,11 +1,8 @@ "Test diskcache.persistent.Index." -import functools as ft import pickle import shutil -import sys import tempfile -from unittest import mock import pytest diff --git a/tests/test_recipes.py b/tests/test_recipes.py index efaa47c..8d13f2b 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -3,7 +3,6 @@ import shutil import threading import time -from unittest import mock import pytest diff --git a/tests/utils.py b/tests/utils.py index 504b930..5b41ce9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,7 +18,6 @@ def percentile(sequence, percent): def secs(value): units = ['s ', 'ms', 'us', 'ns'] - pos = 0 if value is None: return ' 0.000ns' @@ -60,24 +59,6 @@ def unmount_ramdisk(dev_path, path): run('rm', '-r', path) -def retry(sql, query): - pause = 0.001 - error = sqlite3.OperationalError - - for _ in range(int(LIMITS[u'timeout'] / pause)): - try: - sql(query).fetchone() - except sqlite3.OperationalError as exc: - error = exc - time.sleep(pause) - else: - break - else: - raise error - - del error - - def display(name, timings): cols = ('Action', 'Count', 'Miss', 'Median', 'P90', 'P99', 'Max', 'Total') template = ' '.join(['%9s'] * len(cols)) From 4b0fc2342d87f59ca1a024d3f74332dd53e3cb9e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 12:16:28 -0800 Subject: [PATCH 241/329] Update pylint and fix code --- .pylintrc | 90 ++++++++++++++++++++++++++------------------ diskcache/core.py | 6 +-- diskcache/fanout.py | 2 +- diskcache/recipes.py | 8 ++-- 4 files changed, 61 insertions(+), 45 deletions(-) diff --git a/.pylintrc b/.pylintrc index 8458ed4..158fe34 100644 --- a/.pylintrc +++ b/.pylintrc @@ -5,6 +5,9 @@ # run arbitrary code. extension-pkg-whitelist= +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS @@ -26,16 +29,13 @@ jobs=1 # complex, nested conditions. limit-inference-results=100 -# List of plugins (as comma separated values of python modules names) to load, +# List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes -# Specify a configuration file. -#rcfile= - # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes @@ -139,12 +139,10 @@ disable=print-statement, deprecated-sys-function, exception-escape, comprehension-escape, - no-else-return, no-member, - useless-object-inheritance, + no-else-return, + duplicate-code, inconsistent-return-statements, - ungrouped-imports, - not-callable, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -155,11 +153,11 @@ enable=c-extension-no-member [REPORTS] -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string @@ -181,7 +179,7 @@ score=yes [REFACTORING] # Maximum number of nested blocks for function / method body -max-nested-blocks=6 +max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then @@ -192,8 +190,8 @@ never-returning-functions=sys.exit [LOGGING] -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. logging-format-style=old # Logging modules to check that the string format arguments are in logging @@ -206,18 +204,18 @@ logging-modules=logging # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= -# A path to a file that contains private dictionary; one word per line. +# A path to a file that contains the private dictionary; one word per line. spelling-private-dict-file= -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. spelling-store-unknown-words=no @@ -228,6 +226,9 @@ notes=FIXME, XXX, TODO +# Regular expression of note tags to take in consideration. +#notes-rgx= + [TYPECHECK] @@ -264,7 +265,7 @@ ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It +# and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules= @@ -280,6 +281,9 @@ missing-member-hint-distance=1 # showing a hint for a missing member. missing-member-max-choices=1 +# List of decorators that change the signature of a decorated function. +signature-mutators= + [VARIABLES] @@ -330,14 +334,7 @@ indent-string=' ' max-line-length=100 # Maximum number of lines in a module. -max-module-lines=2500 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator +max-module-lines=3000 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. @@ -357,10 +354,10 @@ ignore-comments=yes ignore-docstrings=yes # Ignore imports when computing similarities. -ignore-imports=no +ignore-imports=yes # Minimum lines number of a similarity. -min-similarity-lines=9 +min-similarity-lines=4 [BASIC] @@ -387,6 +384,10 @@ bad-names=foo, tutu, tata +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + # Naming style matching correct class attribute names. class-attribute-naming-style=any @@ -427,6 +428,10 @@ good-names=i, Run, _ +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + # Include a hint for the correct naming format with invalid-name. include-naming-hint=no @@ -474,14 +479,21 @@ variable-naming-style=snake_case [STRING] -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. check-str-concat-over-line-jumps=no [IMPORTS] +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no @@ -512,13 +524,17 @@ known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, - setUp + setUp, + __post_init__ # List of member names, which should be excluded from the protected access # warning. @@ -543,7 +559,7 @@ max-args=8 # Maximum number of attributes for a class (see R0902). max-attributes=8 -# Maximum number of boolean expressions in an if statement. +# Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 # Maximum number of branch for function / method body. diff --git a/diskcache/core.py b/diskcache/core.py index 419c64f..da4d884 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -109,7 +109,7 @@ def __repr__(self): } -class Disk(object): +class Disk: "Cache key and value serialization for SQLite database and files." def __init__(self, directory, min_file_size=0, pickle_protocol=0): @@ -418,7 +418,7 @@ def args_to_key(base, args, kwargs, typed): return key -class Cache(object): +class Cache: "Disk and file backed cache." def __init__(self, directory=None, timeout=60, disk=Disk, **settings): @@ -2138,7 +2138,7 @@ def cull(self, retry=False): select_policy = EVICTION_POLICY[self.eviction_policy]['cull'] if select_policy is None: - return + return 0 select_filename = select_policy.format(fields='filename', now=now) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 6a13d6e..99384a0 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -13,7 +13,7 @@ from .persistent import Deque, Index -class FanoutCache(object): +class FanoutCache: "Cache that shards keys and values." def __init__( diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 3acddd8..8f7bd32 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -12,7 +12,7 @@ from .core import ENOVAL, args_to_key, full_name -class Averager(object): +class Averager: """Recipe for calculating a running average. Sometimes known as "online statistics," the running average maintains the @@ -63,7 +63,7 @@ def pop(self): return None if count == 0 else total / count -class Lock(object): +class Lock: """Recipe for cross-process and cross-thread lock. >>> import diskcache @@ -111,7 +111,7 @@ def __exit__(self, *exc_info): self.release() -class RLock(object): +class RLock: """Recipe for cross-process and cross-thread re-entrant lock. >>> import diskcache @@ -179,7 +179,7 @@ def __exit__(self, *exc_info): self.release() -class BoundedSemaphore(object): +class BoundedSemaphore: """Recipe for cross-process and cross-thread bounded semaphore. >>> import diskcache From c654aeda6a969eeadc5fd81e1de5faab24a832a4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 12:31:18 -0800 Subject: [PATCH 242/329] Update Sphinx and re-gen conf.py --- docs/Makefile | 222 +++-------------------------------------- docs/conf.py | 271 +++++--------------------------------------------- docs/make.bat | 246 ++------------------------------------------- 3 files changed, 46 insertions(+), 693 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index e3bd50b..d4bb2cb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,216 +1,20 @@ -# Makefile for Sphinx documentation +# Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . BUILDDIR = _build -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -.PHONY: html -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DiskCache.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DiskCache.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/DiskCache" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DiskCache" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." +.PHONY: help Makefile -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py index 2556188..402d3ad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,31 +1,33 @@ -# -*- coding: utf-8 -*- +# Configuration file for the Sphinx documentation builder. # -# DiskCache documentation build configuration file, created by -# sphinx-quickstart on Wed Feb 10 20:20:15 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys sys.path.insert(0, os.path.abspath('..')) + import diskcache -from diskcache import __version__ -# -- General configuration ------------------------------------------------ -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# -- Project information ----------------------------------------------------- + +project = 'DiskCache' +copyright = '2021, Grant Jenks' +author = 'Grant Jenks' + +# The full version, including alpha/beta/rc tags +release = diskcache.__version__ + + +# -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -38,77 +40,13 @@ # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'DiskCache' -copyright = u'2020, Grant Jenks' -author = u'Grant Jenks' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __version__ -# The full version, including alpha/beta/rc tags. -release = version - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. @@ -130,43 +68,11 @@ 'github_type': 'star', } -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - # Custom sidebar templates, maps document names to template names. html_sidebars = { '**': [ @@ -178,134 +84,5 @@ ] } -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'DiskCacheDoc' - def setup(app): - app.add_stylesheet('custom.css') - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'DiskCache.tex', u'DiskCache Documentation', - u'Grant Jenks', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'diskcache', u'DiskCache Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'DiskCache', u'DiskCache Documentation', - author, 'DiskCache', 'Disk and file backed cache.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False + app.add_css_file('custom.css') diff --git a/docs/make.bat b/docs/make.bat index e1a063b..2119f51 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,62 +1,18 @@ @ECHO OFF +pushd %~dp0 + REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) +set SOURCEDIR=. set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) if "%1" == "" goto help -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul +%SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx @@ -69,195 +25,11 @@ if errorlevel 9009 ( exit /b 1 ) -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DiskCache.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DiskCache.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end +popd From d1cf8c528ae21fc8d2dff7afe91299df7e5cd1f7 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 12:32:45 -0800 Subject: [PATCH 243/329] Update copyright year --- LICENSE | 2 +- README.rst | 4 ++-- diskcache/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index d31985f..ca80a22 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016-2020 Grant Jenks +Copyright 2016-2021 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/README.rst b/README.rst index ba2bf3e..969ea10 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ DiskCache: Disk Backed Cache `DiskCache`_ is an Apache2 licensed disk and file backed cache library, written in pure-Python, and compatible with Django. -The cloud-based computing of 2020 puts a premium on memory. Gigabytes of empty +The cloud-based computing of 2021 puts a premium on memory. Gigabytes of empty space is left on disks as processes vie for memory. Among these processes is Memcached (and sometimes Redis) which is used as a cache. Wouldn't it be nice to leverage empty disk space for caching? @@ -387,7 +387,7 @@ Reference License ------- -Copyright 2016-2020 Grant Jenks +Copyright 2016-2021 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/diskcache/__init__.py b/diskcache/__init__.py index befed76..c050a17 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -66,4 +66,4 @@ __build__ = 0x050100 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' -__copyright__ = 'Copyright 2016-2020 Grant Jenks' +__copyright__ = 'Copyright 2016-2021 Grant Jenks' From 6df650543aa3834af84346aefd4be85b74d993af Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 12:34:12 -0800 Subject: [PATCH 244/329] Update readme badges and CI notes --- README.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 969ea10..b7e1ee2 100644 --- a/README.rst +++ b/README.rst @@ -77,16 +77,16 @@ Features - Thread-safe and process-safe - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction -- Developed on Python 3.8 -- Tested on CPython 3.5, 3.6, 3.7, 3.8 +- Developed on Python 3.9 +- Tested on CPython 3.6, 3.7, 3.8, 3.9 - Tested on Linux, Mac OS X, and Windows -- Tested using Travis CI and AppVeyor CI +- Tested using GitHub Actions -.. image:: https://api.travis-ci.org/grantjenks/python-diskcache.svg?branch=master - :target: http://www.grantjenks.com/docs/diskcache/ +.. image:: https://github.com/grantjenks/python-diskcache/workflows/integration/badge.svg + :target: https://github.com/grantjenks/python-diskcache/actions?query=workflow%3Aintegration -.. image:: https://ci.appveyor.com/api/projects/status/github/grantjenks/python-diskcache?branch=master&svg=true - :target: http://www.grantjenks.com/docs/diskcache/ +.. image:: https://github.com/grantjenks/python-diskcache/workflows/release/badge.svg + :target: https://github.com/grantjenks/python-diskcache/actions?query=workflow%3Arelease Quickstart ---------- From 72127020640a383149b643933e89c7ebc577cc5f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:11:21 -0800 Subject: [PATCH 245/329] Pin jedi for ipython --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index a9023a1..2ab91c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ django_redis doc8 flake8 ipython +jedi==0.17.* # Remove after IPython bug fixed. pickleDB pylibmc pylint From e49358a20ee359614a39d94047b27b9cfaf4ab1a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:33:58 -0800 Subject: [PATCH 246/329] Skip help() examples when running doctest --- README.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index b7e1ee2..acf6803 100644 --- a/README.rst +++ b/README.rst @@ -99,7 +99,7 @@ You can access documentation in the interpreter with Python's built-in help function:: >>> import diskcache - >>> help(diskcache) + >>> help(diskcache) # doctest: +SKIP The core of `DiskCache`_ is three data types intended for caching. `Cache`_ objects manage a SQLite database and filesystem directory to store key and @@ -107,26 +107,26 @@ value pairs. `FanoutCache`_ provides a sharding layer to utilize multiple caches and `DjangoCache`_ integrates that with `Django`_:: >>> from diskcache import Cache, FanoutCache, DjangoCache - >>> help(Cache) - >>> help(FanoutCache) - >>> help(DjangoCache) + >>> help(Cache) # doctest: +SKIP + >>> help(FanoutCache) # doctest: +SKIP + >>> help(DjangoCache) # doctest: +SKIP Built atop the caching data types, are `Deque`_ and `Index`_ which work as a cross-process, persistent replacements for Python's ``collections.deque`` and ``dict``. These implement the sequence and mapping container base classes:: >>> from diskcache import Deque, Index - >>> help(Deque) - >>> help(Index) + >>> help(Deque) # doctest: +SKIP + >>> help(Index) # doctest: +SKIP Finally, a number of `recipes`_ for cross-process synchronization are provided using an underlying cache. Features like memoization with cache stampede prevention, cross-process locking, and cross-process throttling are available:: >>> from diskcache import memoize_stampede, Lock, throttle - >>> help(memoize_stampede) - >>> help(Lock) - >>> help(throttle) + >>> help(memoize_stampede) # doctest: +SKIP + >>> help(Lock) # doctest: +SKIP + >>> help(throttle) # doctest: +SKIP Python's docstrings are a quick way to get started but not intended as a replacement for the `DiskCache Tutorial`_ and `DiskCache API Reference`_. From 0d41722a030695f4d988ac21383513c52f2aa073 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:34:44 -0800 Subject: [PATCH 247/329] Fix configs for pytest --- tox.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tox.ini b/tox.ini index d5f43c0..acf7b22 100644 --- a/tox.ini +++ b/tox.ini @@ -7,10 +7,12 @@ commands=pytest deps= django==2.2.* pytest + pytest-cov pytest-django pytest-xdist setenv= DJANGO_SETTINGS_MODULE=tests.settings + PYTHONPATH={toxinidir} [testenv:blue] commands=blue {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tests @@ -81,6 +83,8 @@ addopts= --cov-report=term-missing --cov=diskcache --doctest-glob="*.rst" + --ignore docs/case-study-web-crawler.rst + --ignore docs/sf-python-2017-meetup-talk.rst --ignore tests/benchmark_core.py --ignore tests/benchmark_djangocache.py --ignore tests/benchmark_glob.py From a5d1c73e38fbc917c5503e2e8bcd9a2f834efc3f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:39:33 -0800 Subject: [PATCH 248/329] Add branch coverage and decrease coverage minimum to 96 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index acf7b22..04b7382 100644 --- a/tox.ini +++ b/tox.ini @@ -79,7 +79,7 @@ line_length = 79 addopts= -n auto --cov-branch - --cov-fail-under=100 + --cov-fail-under=96 --cov-report=term-missing --cov=diskcache --doctest-glob="*.rst" From fc38bd97009bd9ce352e8bf01950a2a22b5604b5 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:43:31 -0800 Subject: [PATCH 249/329] Ignore more .coverage files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c496721..67157b8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ # test files/directories /.cache/ -.coverage +.coverage* .pytest_cache/ /.tox/ From 6b6944750acdaf80c6ce28eb8e7a35320ea3a3c0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 13:57:25 -0800 Subject: [PATCH 250/329] Bump version to 5.2.0 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index c050a17..d1021cd 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -62,8 +62,8 @@ pass __title__ = 'diskcache' -__version__ = '5.1.0' -__build__ = 0x050100 +__version__ = '5.2.0' +__build__ = 0x050200 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2021 Grant Jenks' From 619eb6904810440ef952ea425421315a7353ba12 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 18:46:28 -0800 Subject: [PATCH 251/329] Install libmemcached-dev for release action --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c68e5..2a07787 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,11 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Install libmemcached-dev + run: | + sudo apt-get update + sudo apt-get install libmemcached-dev + - name: Set up Python uses: actions/setup-python@v2 with: From e1d7c4aaa6729178ca3216f4c8a75b835f963022 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Fri, 22 Jan 2021 18:47:55 -0800 Subject: [PATCH 252/329] Bump version to 5.2.1 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index d1021cd..428eb30 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -62,8 +62,8 @@ pass __title__ = 'diskcache' -__version__ = '5.2.0' -__build__ = 0x050200 +__version__ = '5.2.1' +__build__ = 0x050201 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2021 Grant Jenks' From 12c1db268d03699de86cf114facd2a5091bfc257 Mon Sep 17 00:00:00 2001 From: Omer Katz Date: Thu, 28 Jan 2021 11:11:33 +0200 Subject: [PATCH 253/329] Add Python 3.9 support trove classifier. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 5f3cd88..7f0561c 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ def run_tests(self): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: CPython', ), ) From 660e53d3afe5789babcf461e4cf7764e90dcf15a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sat, 30 Jan 2021 12:20:10 -0800 Subject: [PATCH 254/329] Run integration on pull requests --- .github/workflows/integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7dfc198..0a44f89 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,6 +1,6 @@ name: integration -on: [push] +on: [push, pull_request] jobs: From 2ba659ece1cb7a2b99430cc805c5631f79ec9e8a Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Sun, 28 Mar 2021 06:59:44 +0300 Subject: [PATCH 255/329] Fix typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index acf6803..c4aa8e9 100644 --- a/README.rst +++ b/README.rst @@ -185,7 +185,7 @@ other projects are shown in the tables below. access. Keys are arbitrary strings, values arbitrary pickle-able objects. * `pickleDB`_ is a lightweight and simple key-value store. It is built upon Python's simplejson module and was inspired by Redis. It is licensed with the - BSD three-caluse license. + BSD three-clause license. .. _`dbm`: https://docs.python.org/3/library/dbm.html .. _`shelve`: https://docs.python.org/3/library/shelve.html From 12400ec12eb32be37c41a4940b545797aae036a6 Mon Sep 17 00:00:00 2001 From: Abhilash Raj Date: Fri, 21 May 2021 14:44:18 -0700 Subject: [PATCH 256/329] Fix the URL to Django documentation for cache. --- docs/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8f8dc0f..58220b2 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -535,7 +535,7 @@ they are guaranteed to be stored in files. The full path is available on the file handle in the `name` attribute. Remember to also include the `Content-Type` header if known. -.. _`Django documentation on caching`: https://docs.djangoproject.com/en/1.9/topics/cache/#the-low-level-cache-api +.. _`Django documentation on caching`: https://docs.djangoproject.com/en/3.2/topics/cache/#the-low-level-cache-api Deque ----- From b460e7409d8d299e208500d640d69e55c1faccec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Gmach?= Date: Mon, 7 Jun 2021 15:11:34 +0200 Subject: [PATCH 257/329] remove leftovers from Travis and AppVeyor Both were removed in favor of GitHub actions. --- docs/conf.py | 1 - tests/stress_test_deque_mp.py | 6 ------ tests/stress_test_index_mp.py | 6 ------ 3 files changed, 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 402d3ad..d725198 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,7 +59,6 @@ 'logo': 'gj-logo.png', 'logo_name': True, 'logo_text_align': 'center', - 'travis_button': True, 'analytics_id': 'UA-19364636-2', 'show_powered_by': False, 'show_related': True, diff --git a/tests/stress_test_deque_mp.py b/tests/stress_test_deque_mp.py index 091ff0e..d6bb56a 100644 --- a/tests/stress_test_deque_mp.py +++ b/tests/stress_test_deque_mp.py @@ -107,12 +107,6 @@ def stress(seed, deque): def test(status=False): - if os.environ.get('TRAVIS') == 'true': - return - - if os.environ.get('APPVEYOR') == 'True': - return - random.seed(SEED) deque = dc.Deque(range(SIZE)) processes = [] diff --git a/tests/stress_test_index_mp.py b/tests/stress_test_index_mp.py index f8718f0..2d290ec 100644 --- a/tests/stress_test_index_mp.py +++ b/tests/stress_test_index_mp.py @@ -94,12 +94,6 @@ def stress(seed, index): def test(status=False): - if os.environ.get('TRAVIS') == 'true': - return - - if os.environ.get('APPVEYOR') == 'True': - return - random.seed(SEED) index = dc.Index(enumerate(range(KEYS))) processes = [] From d20ddb3b2273dee41e73aa72ba182f4331ccba83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Gmach?= Date: Tue, 8 Jun 2021 11:05:03 +0200 Subject: [PATCH 258/329] remove unused imports --- tests/stress_test_deque_mp.py | 1 - tests/stress_test_index_mp.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/stress_test_deque_mp.py b/tests/stress_test_deque_mp.py index d6bb56a..f3b8a48 100644 --- a/tests/stress_test_deque_mp.py +++ b/tests/stress_test_deque_mp.py @@ -2,7 +2,6 @@ import itertools as it import multiprocessing as mp -import os import random import time diff --git a/tests/stress_test_index_mp.py b/tests/stress_test_index_mp.py index 2d290ec..06ed102 100644 --- a/tests/stress_test_index_mp.py +++ b/tests/stress_test_index_mp.py @@ -2,7 +2,6 @@ import itertools as it import multiprocessing as mp -import os import random import time From d9b9d06614a41beff9ebce9b1bc8038d5540394e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 8 Jun 2021 21:42:54 -0700 Subject: [PATCH 259/329] Ignore pylint's consider-using-with in Disk.fetch --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index da4d884..4d0ae05 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -261,7 +261,7 @@ def fetch(self, mode, filename, value, read): :return: corresponding Python value """ - # pylint: disable=no-self-use,unidiomatic-typecheck + # pylint: disable=no-self-use,unidiomatic-typecheck,consider-using-with if mode == MODE_RAW: return bytes(value) if type(value) is sqlite3.Binary else value elif mode == MODE_BINARY: From fac9ad3ffdc289336b7c280e50ca26712f65f8ea Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 30 Aug 2021 22:07:25 -0700 Subject: [PATCH 260/329] Simplify ENOENT handling around fetch() and remove() --- diskcache/core.py | 53 ++++++++++++++--------------------------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 4d0ae05..1c915b1 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -26,14 +26,6 @@ def full_name(func): 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." @@ -328,13 +320,10 @@ def remove(self, filename): try: os.remove(full_path) - except WindowsError: + except OSError: + # OSError may occur if two caches attempt to delete the same + # file at the same time. 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 class JSONDisk(Disk): @@ -1201,13 +1190,10 @@ def get( 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 + # Key was deleted before we could retrieve result. + if self.statistics: + sql(cache_miss) + return default if self.statistics: sql(cache_hit) @@ -1324,11 +1310,8 @@ def pop( 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 + # Key was deleted before we could retrieve result. + return default finally: if filename is not None: self._disk.remove(filename) @@ -1595,10 +1578,8 @@ def pull( 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 + # Key was deleted before we could retrieve result. + continue finally: if name is not None: self._disk.remove(name) @@ -1711,10 +1692,8 @@ def peek( 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 + # Key was deleted before we could retrieve result. + continue finally: if name is not None: self._disk.remove(name) @@ -1794,10 +1773,8 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): 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 + # Key was deleted before we could retrieve result. + continue break if expire_time and tag: From ceff81cde5ecacaf7ff79b50bd90250e988a5db0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 30 Aug 2021 22:11:33 -0700 Subject: [PATCH 261/329] Add doc about IOError --- diskcache/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/diskcache/core.py b/diskcache/core.py index 1c915b1..efa175b 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -251,6 +251,7 @@ def fetch(self, mode, filename, value, read): :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 From aefb2feda735b1f602eee290cac3a1baa95c8c8c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 30 Aug 2021 22:12:36 -0700 Subject: [PATCH 262/329] Add notes about changes to store() and remove() --- diskcache/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index efa175b..2cbcdad 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -187,6 +187,7 @@ def store(self, value, read, key=UNKNOWN): :return: (size, mode, filename, value) tuple for Cache table """ + # TODO: Retry mkdirs!!! # pylint: disable=unidiomatic-typecheck type_value = type(value) min_file_size = self.min_file_size @@ -317,6 +318,7 @@ def remove(self, filename): :param str filename: relative path to file """ + # TODO: Delete dir if empty!!! full_path = op.join(self._directory, filename) try: From 49c5979190bd33b134ab8acfe5b5c95823d567e2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 31 Aug 2021 22:33:14 -0700 Subject: [PATCH 263/329] Update remove to cleanup parent dirs --- diskcache/core.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 2cbcdad..13a1b01 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -309,24 +309,26 @@ def filename(self, key=UNKNOWN, value=UNKNOWN): 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 """ - # TODO: Delete dir if empty!!! - 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 OSError: - # OSError may occur if two caches attempt to delete the same - # file at the same time. - pass + + with cl.suppress(OSError): + os.removedirs(full_dir) class JSONDisk(Disk): From 1acab3b7b0809180b7c5b0863751e18662662cc3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 31 Aug 2021 22:33:38 -0700 Subject: [PATCH 264/329] Remove logic from filename() for creating directories --- diskcache/core.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 13a1b01..ac266e3 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -297,14 +297,6 @@ def filename(self, key=UNKNOWN, value=UNKNOWN): 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 From b86aa9e0ba840821f2b5bcbc064a1283fd4d3e1e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 31 Aug 2021 22:34:03 -0700 Subject: [PATCH 265/329] Modify store() to create the subdirs when writing the file (1 of 4) --- diskcache/core.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index ac266e3..9040fb4 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -206,9 +206,26 @@ def store(self, value, read, key=UNKNOWN): return 0, MODE_RAW, None, sqlite3.Binary(value) else: filename, full_path = self.filename(key, value) + full_dir, _ = op.split(full_path) - with open(full_path, 'xb') as writer: - writer.write(value) + for count in range(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, 'xb') + except OSError: + if count == 10: + # Give up after 10 tries to open the file. + raise + continue + + with writer: + writer.write(value) + + break return len(value), MODE_BINARY, filename, None elif type_value is str: From 879a65a1932377d017cc187661fd880346459b0d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:02:21 -0700 Subject: [PATCH 266/329] Refactor file writing logic to retry makedirs --- diskcache/core.py | 66 +++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 9040fb4..332276a 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -187,7 +187,6 @@ def store(self, value, read, key=UNKNOWN): :return: (size, mode, filename, value) tuple for Cache table """ - # TODO: Retry mkdirs!!! # pylint: disable=unidiomatic-typecheck type_value = type(value) min_file_size = self.min_file_size @@ -206,46 +205,18 @@ def store(self, value, read, key=UNKNOWN): return 0, MODE_RAW, None, sqlite3.Binary(value) else: filename, full_path = self.filename(key, value) - full_dir, _ = op.split(full_path) - - for count in range(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, 'xb') - except OSError: - if count == 10: - # Give up after 10 tries to open the file. - raise - continue - - with writer: - writer.write(value) - - break - + 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) @@ -254,11 +225,34 @@ def store(self, value, read, key=UNKNOWN): 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 + + def _write(self, full_path, iterator, mode, encoding=None): + full_dir, _ = op.split(full_path) - with open(full_path, 'xb') as writer: - writer.write(result) + for count in range(1, 11): + with cl.suppress(OSError): + os.makedirs(full_dir) - return len(result), MODE_PICKLE, filename, None + 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 + + break def fetch(self, mode, filename, value, read): """Convert fields `mode`, `filename`, and `value` from Cache table to From 226a5cfd2ec68a726a1f6d535d6bc5c055cf226f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:39:29 -0700 Subject: [PATCH 267/329] Add test for Lock.locked() --- tests/test_recipes.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 8d13f2b..88612cf 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -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') From 9d5c0b7ebf43bea02eae968f75a9104a699fba46 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:39:47 -0700 Subject: [PATCH 268/329] Test re-entrancy of "rlock" --- tests/test_recipes.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 88612cf..3f330a6 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -55,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) From 14094b31006d2951b64a8b87f2a07bd57efa6525 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:40:23 -0700 Subject: [PATCH 269/329] Delete EACCESS error tests --- tests/test_core.py | 121 --------------------------------------------- 1 file changed, 121 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index bcc79e1..a113443 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -333,26 +333,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 +376,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 +552,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 +966,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 +1017,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()) == [] From 2f18867705a536d8a6f54404856f029923204f08 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:40:36 -0700 Subject: [PATCH 270/329] Test Cache.memoize() with typed kwargs --- tests/test_core.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index a113443..518f99e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1338,3 +1338,10 @@ def fibrec(num): 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}) From 094b873e1a780004d6b07dd3ebbb216898d803e4 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:40:53 -0700 Subject: [PATCH 271/329] Test JSONDisk.get by iterating cache --- tests/test_core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 518f99e..efe3e24 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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) From b92f2fd3abaf9c30a921ee7d287f573f2d6546a6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:41:07 -0700 Subject: [PATCH 272/329] Increase coverage to 97% --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 04b7382..926a2b3 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=97 --cov-report=term-missing --cov=diskcache --doctest-glob="*.rst" From f9503321a78ecffabac670bdd147bb6026af78fa Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:46:16 -0700 Subject: [PATCH 273/329] Add test for cleaning up dirs --- tests/test_core.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index efe3e24..cb44da6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1348,3 +1348,16 @@ def test_memoize_kwargs(cache): 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 From 587f00d97724ef9bd2f66fcfa4935fecfc1f86a3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 6 Sep 2021 22:47:21 -0700 Subject: [PATCH 274/329] Add TODO for testing Disk._write --- tests/test_core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index cb44da6..2d09f94 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1361,3 +1361,11 @@ def test_cleanup_dirs(cache): del cache[i] del_count = len(os.listdir(cache.directory)) assert start_count == del_count + + +# TODO: Add tests for Disk._write +# diskcache/core.py +## Disk._write +# - 234->exit +# - 242-246 +# - 255 From 28aa595a662754e88a0d2665b4a9c5eaeadd93fd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 20:25:34 -0700 Subject: [PATCH 275/329] Add tests for Disk._write --- diskcache/core.py | 2 -- tests/test_core.py | 11 +++++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 332276a..b836e84 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -252,8 +252,6 @@ def _write(self, full_path, iterator, mode, encoding=None): writer.write(chunk) return size - break - def fetch(self, mode, filename, value, read): """Convert fields `mode`, `filename`, and `value` from Cache table to value. diff --git a/tests/test_core.py b/tests/test_core.py index 2d09f94..41cfe51 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1363,9 +1363,8 @@ def test_cleanup_dirs(cache): assert start_count == del_count -# TODO: Add tests for Disk._write -# diskcache/core.py -## Disk._write -# - 234->exit -# - 242-246 -# - 255 +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 From a2e461ad93f7fa99a0d861db614cea2f7e521a6c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 20:38:35 -0700 Subject: [PATCH 276/329] Add a pragma "no cover" statements and increase threshold to 98 --- diskcache/__init__.py | 2 +- diskcache/djangocache.py | 2 +- tox.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 428eb30..b5a6218 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -57,7 +57,7 @@ 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/djangocache.py b/diskcache/djangocache.py index 44f673d..bf9f4d4 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -6,7 +6,7 @@ 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 diff --git a/tox.ini b/tox.ini index 926a2b3..104a084 100644 --- a/tox.ini +++ b/tox.ini @@ -79,7 +79,7 @@ line_length = 79 addopts= -n auto --cov-branch - --cov-fail-under=97 + --cov-fail-under=98 --cov-report=term-missing --cov=diskcache --doctest-glob="*.rst" From ab1484daf149039a468ed087b5073198bc4f21c1 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 20:42:06 -0700 Subject: [PATCH 277/329] Blue fixes (mostly docstring triple quotes) --- diskcache/__init__.py | 1 - diskcache/cli.py | 2 +- diskcache/core.py | 29 ++++++++++++++--------------- diskcache/djangocache.py | 14 +++++++------- diskcache/fanout.py | 12 ++++++------ diskcache/persistent.py | 13 ++++++------- diskcache/recipes.py | 29 ++++++++++++++--------------- tests/benchmark_core.py | 1 - tests/benchmark_djangocache.py | 2 -- tests/benchmark_glob.py | 2 +- tests/benchmark_incr.py | 5 ++--- tests/benchmark_kv_store.py | 1 - tests/issue_109.py | 1 - tests/issue_85.py | 1 - tests/plot.py | 9 ++++----- tests/plot_early_recompute.py | 5 ++--- tests/stress_test_core.py | 10 +++++----- tests/stress_test_fanout.py | 10 +++++----- tests/test_core.py | 7 ++++--- tests/test_deque.py | 2 +- tests/test_djangocache.py | 4 ++-- tests/test_fanout.py | 2 +- tests/test_index.py | 2 +- tests/test_recipes.py | 2 +- tests/utils.py | 6 +++--- 25 files changed, 80 insertions(+), 92 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index b5a6218..934a3a7 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -3,7 +3,6 @@ ======================= The :doc:`tutorial` provides a helpful walkthrough of most methods. - """ from .core import ( diff --git a/diskcache/cli.py b/diskcache/cli.py index 44bffeb..6a39f60 100644 --- a/diskcache/cli.py +++ b/diskcache/cli.py @@ -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 b836e84..4cbf67f 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1,5 +1,4 @@ """Core disk and file backed cache API. - """ import codecs @@ -22,12 +21,12 @@ 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__ class Constant(tuple): - "Pretty display of immutable constant." + """Pretty display of immutable constant.""" def __new__(cls, name): return tuple.__new__(cls, (name,)) @@ -102,7 +101,7 @@ def __repr__(self): 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. @@ -333,7 +332,7 @@ def remove(self, file_path): 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. @@ -374,15 +373,15 @@ def fetch(self, mode, filename, value, read): 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): @@ -414,7 +413,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. @@ -1859,12 +1858,12 @@ def memoize(self, name=None, typed=False, expire=None, tag=None): 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) @@ -1876,7 +1875,7 @@ def wrapper(*args, **kwargs): return result def __cache_key__(*args, **kwargs): - "Make key for cache given function arguments." + """Make key for cache given function arguments.""" return args_to_key(base, args, kwargs, typed) wrapper.__cache_key__ = __cache_key__ @@ -2291,13 +2290,13 @@ def _iter(self, ascending=True): 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 @@ -2355,7 +2354,7 @@ def __exit__(self, *exception): 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 bf9f4d4..449f3a0 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 @@ -15,7 +15,7 @@ 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 @@ def cull(self): 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() @@ -415,12 +415,12 @@ def memoize( 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,7 +444,7 @@ def wrapper(*args, **kwargs): return result def __cache_key__(*args, **kwargs): - "Make key for cache given function arguments." + """Make key for cache given function arguments.""" return args_to_key(base, args, kwargs, typed) wrapper.__cache_key__ = __cache_key__ 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 @@ 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 @@ def volume(self): 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 @@ def __setstate__(self, state): 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..89c3899 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1,5 +1,4 @@ """Persistent Data Types - """ import operator as op @@ -18,10 +17,10 @@ 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 @@ def fromcache(cls, cache, iterable=()): @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 @@ def fromcache(cls, cache, *args, **kwargs): @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): diff --git a/diskcache/recipes.py b/diskcache/recipes.py index 8f7bd32..0c02dd7 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -1,5 +1,4 @@ """Disk Cache Recipes - """ import functools @@ -40,7 +39,7 @@ def __init__(self, cache, key, expire=None, tag=None): 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 @@ def add(self, value): ) 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 @@ def __init__(self, cache, key, expire=None, tag=None): 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 @@ def acquire(self): 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 @@ def __init__(self, cache, key, expire=None, tag=None): 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 @@ def acquire(self): 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 @@ def __init__(self, cache, key, value=1, expire=None, tag=None): 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 @@ def acquire(self): 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' @@ -396,11 +395,11 @@ def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1): """ # 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 +407,7 @@ def timer(*args, **kwargs): @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,7 +458,7 @@ def recompute(): return pair[0] def __cache_key__(*args, **kwargs): - "Make key for cache given function arguments." + """Make key for cache given function arguments.""" return args_to_key(base, args, kwargs, typed) wrapper.__cache_key__ = __cache_key__ 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 @@ 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 @@ 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 repeat(num): 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 41cfe51..c1e7a4a 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 @@ -1347,11 +1347,12 @@ 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 + value = b'\0' * 2 ** 20 start_count = len(os.listdir(cache.directory)) for i in range(10): cache[i] = value @@ -1367,4 +1368,4 @@ 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 + cache[0] = '\0' * 2 ** 20 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 @@ def __getstate__(self): ) ) 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 3f330a6..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 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) From 72bbd73d29184e095df248efd48c31eeeb8e992c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 20:58:43 -0700 Subject: [PATCH 278/329] Pylint fixes --- diskcache/core.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 4cbf67f..2e946ad 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -46,25 +46,25 @@ def __repr__(self): 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 = { @@ -1194,7 +1194,7 @@ def get( try: value = self._disk.fetch(mode, filename, db_value, read) - except IOError as error: + except IOError: # Key was deleted before we could retrieve result. if self.statistics: sql(cache_miss) @@ -1314,7 +1314,7 @@ def pop( try: value = self._disk.fetch(mode, filename, db_value, False) - except IOError as error: + except IOError: # Key was deleted before we could retrieve result. return default finally: @@ -1582,7 +1582,7 @@ def pull( try: value = self._disk.fetch(mode, name, db_value, False) - except IOError as error: + except IOError: # Key was deleted before we could retrieve result. continue finally: @@ -1696,7 +1696,7 @@ def peek( try: value = self._disk.fetch(mode, name, db_value, False) - except IOError as error: + except IOError: # Key was deleted before we could retrieve result. continue finally: @@ -1777,7 +1777,7 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): try: value = self._disk.fetch(mode, name, db_value, False) - except IOError as error: + except IOError: # Key was deleted before we could retrieve result. continue break @@ -1911,7 +1911,7 @@ def check(self, fix=False, retry=False): 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) From 3e87128d4154acf90bd64c6630b1200108dc1ed2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 21:04:34 -0700 Subject: [PATCH 279/329] Disable no-self-use in Disk._write --- diskcache/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/diskcache/core.py b/diskcache/core.py index 2e946ad..251c5a2 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -228,6 +228,7 @@ def store(self, value, read, key=UNKNOWN): return len(result), MODE_PICKLE, filename, None def _write(self, full_path, iterator, mode, encoding=None): + # pylint: disable=no-self-use full_dir, _ = op.split(full_path) for count in range(1, 11): From fbc537a7138330652d0d42aa09caa5531746b302 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 21:51:30 -0700 Subject: [PATCH 280/329] Add `ignore` to memoize() --- diskcache/core.py | 10 +++++++--- diskcache/djangocache.py | 4 +++- diskcache/persistent.py | 5 +++-- diskcache/recipes.py | 5 +++-- tests/test_core.py | 13 +++++++++++++ 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 251c5a2..4035293 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -385,19 +385,22 @@ class EmptyDirWarning(UserWarning): """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()) @@ -1792,7 +1795,7 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): 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. @@ -1851,6 +1854,7 @@ def memoize(self, name=None, typed=False, expire=None, tag=None): :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 """ @@ -1877,7 +1881,7 @@ def wrapper(*args, **kwargs): def __cache_key__(*args, **kwargs): """Make key for cache given function arguments.""" - return args_to_key(base, args, kwargs, typed) + return args_to_key(base, args, kwargs, typed, ignore) wrapper.__cache_key__ = __cache_key__ return wrapper diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 449f3a0..347a613 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -373,6 +373,7 @@ def memoize( version=None, typed=False, tag=None, + ignore=(), ): """Memoizing cache decorator. @@ -407,6 +408,7 @@ def memoize( :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 """ @@ -445,7 +447,7 @@ def wrapper(*args, **kwargs): def __cache_key__(*args, **kwargs): """Make key for cache given function arguments.""" - return args_to_key(base, args, kwargs, typed) + return args_to_key(base, args, kwargs, typed, ignore) wrapper.__cache_key__ = __cache_key__ return wrapper diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 89c3899..9b5939b 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1096,7 +1096,7 @@ def __ne__(self, other): """ 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. @@ -1147,10 +1147,11 @@ def memoize(self, name=None, typed=False): :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 0c02dd7..b345560 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -337,7 +337,7 @@ def wrapper(*args, **kwargs): 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 @@ -390,6 +390,7 @@ 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 """ @@ -459,7 +460,7 @@ def recompute(): def __cache_key__(*args, **kwargs): """Make key for cache given function arguments.""" - return args_to_key(base, args, kwargs, typed) + return args_to_key(base, args, kwargs, typed, ignore) wrapper.__cache_key__ = __cache_key__ return wrapper diff --git a/tests/test_core.py b/tests/test_core.py index c1e7a4a..0a1fca6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1369,3 +1369,16 @@ def test_disk_write_os_error(cache): 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) From bd800aa069ad6bc15aa3f6fec84ae92d2f644de5 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 21:52:05 -0700 Subject: [PATCH 281/329] Fixes for blue --- diskcache/core.py | 4 +++- diskcache/recipes.py | 4 +++- tests/test_core.py | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index 4035293..d7f707b 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1795,7 +1795,9 @@ def peekitem(self, last=True, expire_time=False, tag=False, retry=False): else: return key, value - def memoize(self, name=None, typed=False, expire=None, tag=None, ignore=()): + def memoize( + self, name=None, typed=False, expire=None, tag=None, ignore=() + ): """Memoizing cache decorator. Decorator to wrap callable with memoizing function using cache. diff --git a/diskcache/recipes.py b/diskcache/recipes.py index b345560..b5af6dd 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -337,7 +337,9 @@ def wrapper(*args, **kwargs): return decorator -def memoize_stampede(cache, expire, name=None, typed=False, tag=None, beta=1, ignore=()): +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 diff --git a/tests/test_core.py b/tests/test_core.py index 0a1fca6..b3d3d66 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1372,7 +1372,6 @@ def test_disk_write_os_error(cache): def test_memoize_ignore(cache): - @cache.memoize(ignore={1, 'arg1'}) def test(*args, **kwargs): return args, kwargs From 606d8f2b12e8022e6d5b509d7c41439c4242043d Mon Sep 17 00:00:00 2001 From: Abhinav Omprakash <55880260+AbhinavOmprakash@users.noreply.github.com> Date: Fri, 11 Jun 2021 09:54:54 +0530 Subject: [PATCH 282/329] Fixes #201 added github repo to project_urls --- setup.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7f0561c..b29a463 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,12 @@ def run_tests(self): 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'], From c22d3ee59ee28bd58aec59182d375b1eccd191f2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 22:03:57 -0700 Subject: [PATCH 283/329] Fixup formatting for project urls --- setup.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index b29a463..b49d9c8 100644 --- a/setup.py +++ b/setup.py @@ -29,12 +29,13 @@ def run_tests(self): long_description=readme, author='Grant Jenks', author_email='contact@grantjenks.com', - 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',} + url='http://www.grantjenks.com/docs/diskcache/', + project_urls={ + 'Documentation': 'http://www.grantjenks.com/docs/diskcache/', + 'Funding': 'https://gum.co/diskcache', + 'Source': 'https://github.com/grantjenks/python-diskcache', + 'Tracker': 'https://github.com/grantjenks/python-diskcache/issues', + }, license='Apache 2.0', packages=['diskcache'], tests_require=['tox'], From d55a50ee083784afa9c85e14e41c4a2d132f3111 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 22:15:50 -0700 Subject: [PATCH 284/329] Stop using ENOVAL in args_to_key() --- diskcache/core.py | 3 +-- tests/test_core.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index d7f707b..fb343be 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -397,11 +397,10 @@ def args_to_key(base, args, kwargs, typed, ignore): """ args = tuple(arg for index, arg in enumerate(args) if index not in ignore) - key = base + args + key = base + args + (None,) if kwargs: kwargs = {key: val for key, val in kwargs.items() if key not in ignore} - key += (ENOVAL,) sorted_items = sorted(kwargs.items()) for item in sorted_items: diff --git a/tests/test_core.py b/tests/test_core.py index b3d3d66..55ca962 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -92,6 +92,8 @@ def test_custom_disk(): for key, value in zip(cache, values): assert key == value + test_memoize_iter(cache) + shutil.rmtree(cache.directory, ignore_errors=True) @@ -1381,3 +1383,17 @@ def test(*args, **kwargs): 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) + + +def test_memoize_iter(cache): + @cache.memoize() + def test(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + cache.clear() + assert test(1, 2, 3) + assert test(a=1, b=2, c=3) + assert test(-1, 0, 1, a=1, b=2, c=3) + assert len(cache) == 3 + for key in cache: + assert cache[key] == 6 From b10b3866f4d64ea197e3fb40c0ebe25aa32900d6 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 22:29:21 -0700 Subject: [PATCH 285/329] Add caveat about inconsistent pickles --- docs/tutorial.rst | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 58220b2..3191af6 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -863,8 +863,17 @@ protocol`_ is not used. Neither the `__hash__` nor `__eq__` methods are used for lookups. Instead lookups depend on the serialization method defined by :class:`Disk ` objects. For strings, bytes, integers, and floats, equality matches Python's definition. But large integers and all other -types will be converted to bytes using pickling and the bytes representation -will define equality. +types will be converted to bytes and the bytes representation will define +equality. + +The default :class:`diskcache.Disk` serialization uses pickling for both keys +and values. Unfortunately, pickling produces inconsistencies sometimes when +applied to container data types like tuples. Two equal tuples may serialize to +different bytes objects using pickle. The likelihood of differences is reduced +by using `pickletools.optimize` but still inconsistencies occur (`#54`_). The +inconsistent serialized pickle values is particularly problematic when applied +to the key in the cache. Consider using an alternative Disk type, like +:class:`JSONDisk `, for consistent serialization of keys. SQLite is used to synchronize database access between threads and processes and as such inherits all SQLite caveats. Most notably SQLite is `not recommended`_ @@ -898,6 +907,7 @@ does not account the size of directories themselves or other filesystem metadata. If directory count or size is a concern then consider implementing an alternative :class:`Disk `. +.. _`#54`: https://github.com/grantjenks/python-diskcache/issues/54 .. _`hash protocol`: https://docs.python.org/library/functions.html#hash .. _`not recommended`: https://www.sqlite.org/faq.html#q5 .. _`performs poorly`: https://www.pythonanywhere.com/forums/topic/1847/ From 3ad6c0e4365ef93e60a75aee75740e6551e279f9 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 13 Sep 2021 22:40:26 -0700 Subject: [PATCH 286/329] Bug Fix: Use "ignore" keyword argument with Index.memoize() --- diskcache/persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 9b5939b..c3d570b 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -1151,7 +1151,7 @@ def memoize(self, name=None, typed=False, ignore=()): :return: callable decorator """ - return self._cache.memoize(name, typed, ignore) + return self._cache.memoize(name, typed, ignore=ignore) @contextmanager def transact(self): From 4de3c0ed99dd2bcebffad8e6e0cb7a2885210a97 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Tue, 14 Sep 2021 14:07:00 -0700 Subject: [PATCH 287/329] Drop old Ubuntu from integration testing --- .github/workflows/integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0a44f89..b1143a4 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: strategy: max-parallel: 8 matrix: - os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-16.04] + os: [ubuntu-latest, macos-latest, windows-latest] python-version: [3.6, 3.7, 3.8, 3.9] steps: From 6ed012a655673c54468beb7ff00038443ed2595e Mon Sep 17 00:00:00 2001 From: artiom Date: Wed, 29 Sep 2021 10:57:36 +0100 Subject: [PATCH 288/329] docs: fix typo --- docs/tutorial.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 3191af6..1963635 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -570,7 +570,7 @@ access and editing at both front and back sides. :class:`Deque cross-thread and cross-process communication. :class:`Deque ` objects are also useful in scenarios where contents should remain persistent or limitations prohibit holding all items in memory at the same time. The deque -uses a fixed amout of memory regardless of the size or number of items stored +uses a fixed amount of memory regardless of the size or number of items stored inside it. Index @@ -603,7 +603,7 @@ interface. :class:`Index ` objects inherit all the benefits of cross-thread and cross-process communication. :class:`Index ` objects are also useful in scenarios where contents should remain persistent or limitations prohibit holding all items in memory at the same time. The index -uses a fixed amout of memory regardless of the size or number of items stored +uses a fixed amount of memory regardless of the size or number of items stored inside it. .. _tutorial-transactions: From 20f1d93a6e3852bac4289cb94e27585d9c23330c Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 29 Sep 2021 08:11:04 -0700 Subject: [PATCH 289/329] Disable consider-using-f-string --- .pylintrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.pylintrc b/.pylintrc index 158fe34..6baa978 100644 --- a/.pylintrc +++ b/.pylintrc @@ -143,6 +143,7 @@ disable=print-statement, no-else-return, duplicate-code, inconsistent-return-statements, + consider-using-f-string, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option From 7bfbce63ba127d601ddfbf2838539e34cad87616 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 29 Nov 2021 22:58:48 -0800 Subject: [PATCH 290/329] Support for Python 3.10 in testing (#238) * Add support for Python 3.10 * Update copyright to 2022 * Bump version to 5.3.0 * Add Python 3.10 to the README --- .github/workflows/integration.yml | 4 ++-- .github/workflows/release.yml | 2 +- LICENSE | 2 +- README.rst | 6 +++--- diskcache/__init__.py | 6 +++--- docs/conf.py | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b1143a4..07a5650 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | pip install --upgrade pip @@ -32,7 +32,7 @@ jobs: max-parallel: 8 matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9, '3.10'] steps: - name: Set up Python ${{ matrix.python-version }} x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a07787..1f89c14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | diff --git a/LICENSE b/LICENSE index ca80a22..bb4cfb7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016-2021 Grant Jenks +Copyright 2016-2022 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/README.rst b/README.rst index c4aa8e9..eb06a6a 100644 --- a/README.rst +++ b/README.rst @@ -77,8 +77,8 @@ Features - Thread-safe and process-safe - Supports multiple eviction policies (LRU and LFU included) - Keys support "tag" metadata and eviction -- Developed on Python 3.9 -- Tested on CPython 3.6, 3.7, 3.8, 3.9 +- Developed on Python 3.10 +- Tested on CPython 3.6, 3.7, 3.8, 3.9, 3.10 - Tested on Linux, Mac OS X, and Windows - Tested using GitHub Actions @@ -387,7 +387,7 @@ Reference License ------- -Copyright 2016-2021 Grant Jenks +Copyright 2016-2022 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 934a3a7..c361ca9 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.2.1' -__build__ = 0x050201 +__version__ = '5.3.0' +__build__ = 0x050300 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' -__copyright__ = 'Copyright 2016-2021 Grant Jenks' +__copyright__ = 'Copyright 2016-2022 Grant Jenks' diff --git a/docs/conf.py b/docs/conf.py index d725198..92ce1b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'DiskCache' -copyright = '2021, Grant Jenks' +copyright = '2022, Grant Jenks' author = 'Grant Jenks' # The full version, including alpha/beta/rc tags From 78a5cc690e0aa53ad3537a72b2f52f3c161da1ae Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:35:44 -0800 Subject: [PATCH 291/329] Update tests for Django 3.2 --- tests/test_djangocache.py | 266 +++++++++++++++++++++++++------------- 1 file changed, 176 insertions(+), 90 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index cdaf101..36e1b01 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -1,5 +1,5 @@ # Most of this file was copied from: -# https://raw.githubusercontent.com/django/django/stable/2.2.x/tests/cache/tests.py +# https://raw.githubusercontent.com/django/django/stable/3.2.x/tests/cache/tests.py # Unit tests for cache framework # Uses whatever cache backend is set in the test settings file. @@ -9,12 +9,14 @@ import pickle import re import shutil +import sys import tempfile import threading import time import unittest import warnings -from unittest import mock +from pathlib import Path +from unittest import mock, skipIf from django.conf import settings from django.core import management, signals @@ -88,6 +90,10 @@ def __getstate__(self): raise pickle.PickleError() +def empty_response(request): + return HttpResponse() + + KEY_ERRORS_WITH_MEMCACHED_MSG = ( 'Cache key contains characters that will cause errors if used with ' 'memcached: %r' @@ -138,6 +144,14 @@ class BaseCacheTests: # A common set of tests to apply to all cache backends factory = RequestFactory() + # RemovedInDjango41Warning: python-memcached doesn't support .get() with + # default. + supports_get_with_default = True + + # Some clients raise custom exceptions when .incr() or .decr() are called + # with a non-integer value. + incr_decr_type_error = TypeError + def tearDown(self): cache.clear() @@ -146,11 +160,15 @@ def test_simple(self): cache.set('key', 'value') self.assertEqual(cache.get('key'), 'value') + def test_default_used_when_none_is_set(self): + """If None is cached, get() returns it instead of the default.""" + cache.set('key_default_none', None) + self.assertIsNone(cache.get('key_default_none', default='default')) + def test_add(self): # A key can be added to a cache - cache.add('addkey1', 'value') - result = cache.add('addkey1', 'newvalue') - self.assertFalse(result) + self.assertIs(cache.add('addkey1', 'value'), True) + self.assertIs(cache.add('addkey1', 'newvalue'), False) self.assertEqual(cache.get('addkey1'), 'value') def test_prefix(self): @@ -158,7 +176,7 @@ def test_prefix(self): cache.set('somekey', 'value') # should not be set in the prefixed cache - self.assertFalse(caches['prefix'].has_key('somekey')) + self.assertIs(caches['prefix'].has_key('somekey'), False) caches['prefix'].set('somekey', 'value2') @@ -180,28 +198,43 @@ def test_get_many(self): self.assertEqual( cache.get_many(iter(['a', 'b', 'e'])), {'a': 'a', 'b': 'b'} ) + cache.set_many({'x': None, 'y': 1}) + self.assertEqual(cache.get_many(['x', 'y']), {'x': None, 'y': 1}) def test_delete(self): # Cache keys can be deleted cache.set_many({'key1': 'spam', 'key2': 'eggs'}) self.assertEqual(cache.get('key1'), 'spam') - cache.delete('key1') + self.assertIs(cache.delete('key1'), True) self.assertIsNone(cache.get('key1')) self.assertEqual(cache.get('key2'), 'eggs') + def test_delete_nonexistent(self): + self.assertIs(cache.delete('nonexistent_key'), False) + def test_has_key(self): # The cache can be inspected for cache keys cache.set('hello1', 'goodbye1') - self.assertTrue(cache.has_key('hello1')) - self.assertFalse(cache.has_key('goodbye1')) + self.assertIs(cache.has_key('hello1'), True) + self.assertIs(cache.has_key('goodbye1'), False) cache.set('no_expiry', 'here', None) - self.assertTrue(cache.has_key('no_expiry')) + self.assertIs(cache.has_key('no_expiry'), True) + cache.set('null', None) + self.assertIs( + cache.has_key('null'), + True if self.supports_get_with_default else False, + ) def test_in(self): # The in operator can be used to inspect cache contents cache.set('hello2', 'goodbye2') self.assertIn('hello2', cache) self.assertNotIn('goodbye2', cache) + cache.set('null', None) + if self.supports_get_with_default: + self.assertIn('null', cache) + else: + self.assertNotIn('null', cache) def test_incr(self): # Cache values can be incremented @@ -213,6 +246,9 @@ def test_incr(self): self.assertEqual(cache.incr('answer', -10), 42) with self.assertRaises(ValueError): cache.incr('does_not_exist') + cache.set('null', None) + with self.assertRaises(self.incr_decr_type_error): + cache.incr('null') def test_decr(self): # Cache values can be decremented @@ -224,6 +260,9 @@ def test_decr(self): self.assertEqual(cache.decr('answer', -10), 42) with self.assertRaises(ValueError): cache.decr('does_not_exist') + cache.set('null', None) + with self.assertRaises(self.incr_decr_type_error): + cache.decr('null') def test_close(self): self.assertTrue(hasattr(cache, 'close')) @@ -295,24 +334,23 @@ def test_expiration(self): time.sleep(2) self.assertIsNone(cache.get('expire1')) - cache.add('expire2', 'newvalue') + self.assertIs(cache.add('expire2', 'newvalue'), True) self.assertEqual(cache.get('expire2'), 'newvalue') - self.assertFalse(cache.has_key('expire3')) + self.assertIs(cache.has_key('expire3'), False) def test_touch(self): # cache.touch() updates the timeout. cache.set('expire1', 'very quickly', timeout=1) self.assertIs(cache.touch('expire1', timeout=4), True) time.sleep(2) - self.assertTrue(cache.has_key('expire1')) + self.assertIs(cache.has_key('expire1'), True) time.sleep(3) - self.assertFalse(cache.has_key('expire1')) - + self.assertIs(cache.has_key('expire1'), False) # cache.touch() works without the timeout argument. cache.set('expire1', 'very quickly', timeout=1) self.assertIs(cache.touch('expire1'), True) time.sleep(2) - self.assertTrue(cache.has_key('expire1')) + self.assertIs(cache.has_key('expire1'), True) self.assertIs(cache.touch('nonexistent'), False) @@ -333,13 +371,13 @@ def test_unicode(self): # Test `add` for (key, value) in stuff.items(): with self.subTest(key=key): - cache.delete(key) - cache.add(key, value) + self.assertIs(cache.delete(key), True) + self.assertIs(cache.add(key, value), True) self.assertEqual(cache.get(key), value) # Test `set_many` for (key, value) in stuff.items(): - cache.delete(key) + self.assertIs(cache.delete(key), True) cache.set_many(stuff) for (key, value) in stuff.items(): with self.subTest(key=key): @@ -359,7 +397,7 @@ def test_binary_string(self): self.assertEqual(value, decompress(compressed_result).decode()) # Test add - cache.add('binary1-add', compressed_value) + self.assertIs(cache.add('binary1-add', compressed_value), True) compressed_result = cache.get('binary1-add') self.assertEqual(compressed_value, compressed_result) self.assertEqual(value, decompress(compressed_result).decode()) @@ -405,14 +443,14 @@ def test_clear(self): def test_long_timeout(self): """ - Followe memcached's convention where a timeout greater than 30 days is + Follow memcached's convention where a timeout greater than 30 days is treated as an absolute expiration timestamp instead of a relative offset (#12399). """ cache.set('key1', 'eggs', 60 * 60 * 24 * 30 + 1) # 30 days + 1 second self.assertEqual(cache.get('key1'), 'eggs') - cache.add('key2', 'ham', 60 * 60 * 24 * 30 + 1) + self.assertIs(cache.add('key2', 'ham', 60 * 60 * 24 * 30 + 1), True) self.assertEqual(cache.get('key2'), 'ham') cache.set_many( @@ -429,10 +467,9 @@ def test_forever_timeout(self): cache.set('key1', 'eggs', None) self.assertEqual(cache.get('key1'), 'eggs') - cache.add('key2', 'ham', None) + self.assertIs(cache.add('key2', 'ham', None), True) self.assertEqual(cache.get('key2'), 'ham') - added = cache.add('key1', 'new eggs', None) - self.assertIs(added, False) + self.assertIs(cache.add('key1', 'new eggs', None), False) self.assertEqual(cache.get('key1'), 'eggs') cache.set_many({'key3': 'sausage', 'key4': 'lobster bisque'}, None) @@ -440,7 +477,7 @@ def test_forever_timeout(self): self.assertEqual(cache.get('key4'), 'lobster bisque') cache.set('key5', 'belgian fries', timeout=1) - cache.touch('key5', timeout=None) + self.assertIs(cache.touch('key5', timeout=None), True) time.sleep(2) self.assertEqual(cache.get('key5'), 'belgian fries') @@ -451,7 +488,7 @@ def test_zero_timeout(self): cache.set('key1', 'eggs', 0) self.assertIsNone(cache.get('key1')) - cache.add('key2', 'ham', 0) + self.assertIs(cache.add('key2', 'ham', 0), True) self.assertIsNone(cache.get('key2')) cache.set_many({'key3': 'sausage', 'key4': 'lobster bisque'}, 0) @@ -459,7 +496,7 @@ def test_zero_timeout(self): self.assertIsNone(cache.get('key4')) cache.set('key5', 'belgian fries', timeout=5) - cache.touch('key5', timeout=0) + self.assertIs(cache.touch('key5', timeout=0), True) self.assertIsNone(cache.get('key5')) def test_float_timeout(self): @@ -467,7 +504,12 @@ def test_float_timeout(self): cache.set('key1', 'spam', 100.2) self.assertEqual(cache.get('key1'), 'spam') - def _perform_cull_test(self, cull_cache, initial_count, final_count): + def _perform_cull_test(self, cull_cache_name, initial_count, final_count): + try: + cull_cache = caches[cull_cache_name] + except InvalidCacheBackendError: + self.skipTest("Culling isn't implemented.") + # Create initial cache key entries. This will overflow the cache, # causing a cull. for i in range(1, initial_count): @@ -480,10 +522,24 @@ def _perform_cull_test(self, cull_cache, initial_count, final_count): self.assertEqual(count, final_count) def test_cull(self): - self._perform_cull_test(caches['cull'], 50, 29) + self._perform_cull_test('cull', 50, 29) def test_zero_cull(self): - self._perform_cull_test(caches['zero_cull'], 50, 19) + self._perform_cull_test('zero_cull', 50, 19) + + def test_cull_delete_when_store_empty(self): + try: + cull_cache = caches['cull'] + except InvalidCacheBackendError: + self.skipTest("Culling isn't implemented.") + old_max_entries = cull_cache._max_entries + # Force _cull to delete on first cached record. + cull_cache._max_entries = -1 + try: + cull_cache.set('force_cull_delete', 'value', 1000) + self.assertIs(cull_cache.has_key('force_cull_delete'), True) + finally: + cull_cache._max_entries = old_max_entries def _perform_invalid_key_test(self, key, expected_warning): """ @@ -500,10 +556,24 @@ def func(key, *args): old_func = cache.key_func cache.key_func = func + tests = [ + ('add', [key, 1]), + ('get', [key]), + ('set', [key, 1]), + ('incr', [key]), + ('decr', [key]), + ('touch', [key]), + ('delete', [key]), + ('get_many', [[key, 'b']]), + ('set_many', [{key: 1, 'b': 2}]), + ('delete_many', [{key: 1, 'b': 2}]), + ] try: - with self.assertWarns(CacheKeyWarning) as cm: - cache.set(key, 'value') - self.assertEqual(str(cm.warning), expected_warning) + for operation, args in tests: + with self.subTest(operation=operation): + with self.assertWarns(CacheKeyWarning) as cm: + getattr(cache, operation)(*args) + self.assertEqual(str(cm.warning), expected_warning) finally: cache.key_func = old_func @@ -567,41 +637,41 @@ def test_cache_versioning_get_set(self): def test_cache_versioning_add(self): # add, default version = 1, but manually override version = 2 - cache.add('answer1', 42, version=2) + self.assertIs(cache.add('answer1', 42, version=2), True) self.assertIsNone(cache.get('answer1', version=1)) self.assertEqual(cache.get('answer1', version=2), 42) - cache.add('answer1', 37, version=2) + self.assertIs(cache.add('answer1', 37, version=2), False) self.assertIsNone(cache.get('answer1', version=1)) self.assertEqual(cache.get('answer1', version=2), 42) - cache.add('answer1', 37, version=1) + self.assertIs(cache.add('answer1', 37, version=1), True) self.assertEqual(cache.get('answer1', version=1), 37) self.assertEqual(cache.get('answer1', version=2), 42) # v2 add, using default version = 2 - caches['v2'].add('answer2', 42) + self.assertIs(caches['v2'].add('answer2', 42), True) self.assertIsNone(cache.get('answer2', version=1)) self.assertEqual(cache.get('answer2', version=2), 42) - caches['v2'].add('answer2', 37) + self.assertIs(caches['v2'].add('answer2', 37), False) self.assertIsNone(cache.get('answer2', version=1)) self.assertEqual(cache.get('answer2', version=2), 42) - caches['v2'].add('answer2', 37, version=1) + self.assertIs(caches['v2'].add('answer2', 37, version=1), True) self.assertEqual(cache.get('answer2', version=1), 37) self.assertEqual(cache.get('answer2', version=2), 42) # v2 add, default version = 2, but manually override version = 1 - caches['v2'].add('answer3', 42, version=1) + self.assertIs(caches['v2'].add('answer3', 42, version=1), True) self.assertEqual(cache.get('answer3', version=1), 42) self.assertIsNone(cache.get('answer3', version=2)) - caches['v2'].add('answer3', 37, version=1) + self.assertIs(caches['v2'].add('answer3', 37, version=1), False) self.assertEqual(cache.get('answer3', version=1), 42) self.assertIsNone(cache.get('answer3', version=2)) - caches['v2'].add('answer3', 37) + self.assertIs(caches['v2'].add('answer3', 37), True) self.assertEqual(cache.get('answer3', version=1), 42) self.assertEqual(cache.get('answer3', version=2), 37) @@ -609,73 +679,73 @@ def test_cache_versioning_has_key(self): cache.set('answer1', 42) # has_key - self.assertTrue(cache.has_key('answer1')) - self.assertTrue(cache.has_key('answer1', version=1)) - self.assertFalse(cache.has_key('answer1', version=2)) + self.assertIs(cache.has_key('answer1'), True) + self.assertIs(cache.has_key('answer1', version=1), True) + self.assertIs(cache.has_key('answer1', version=2), False) - self.assertFalse(caches['v2'].has_key('answer1')) - self.assertTrue(caches['v2'].has_key('answer1', version=1)) - self.assertFalse(caches['v2'].has_key('answer1', version=2)) + self.assertIs(caches['v2'].has_key('answer1'), False) + self.assertIs(caches['v2'].has_key('answer1', version=1), True) + self.assertIs(caches['v2'].has_key('answer1', version=2), False) def test_cache_versioning_delete(self): cache.set('answer1', 37, version=1) cache.set('answer1', 42, version=2) - cache.delete('answer1') + self.assertIs(cache.delete('answer1'), True) self.assertIsNone(cache.get('answer1', version=1)) self.assertEqual(cache.get('answer1', version=2), 42) cache.set('answer2', 37, version=1) cache.set('answer2', 42, version=2) - cache.delete('answer2', version=2) + self.assertIs(cache.delete('answer2', version=2), True) self.assertEqual(cache.get('answer2', version=1), 37) self.assertIsNone(cache.get('answer2', version=2)) cache.set('answer3', 37, version=1) cache.set('answer3', 42, version=2) - caches['v2'].delete('answer3') + self.assertIs(caches['v2'].delete('answer3'), True) self.assertEqual(cache.get('answer3', version=1), 37) self.assertIsNone(cache.get('answer3', version=2)) cache.set('answer4', 37, version=1) cache.set('answer4', 42, version=2) - caches['v2'].delete('answer4', version=1) + self.assertIs(caches['v2'].delete('answer4', version=1), True) self.assertIsNone(cache.get('answer4', version=1)) self.assertEqual(cache.get('answer4', version=2), 42) def test_cache_versioning_incr_decr(self): cache.set('answer1', 37, version=1) cache.set('answer1', 42, version=2) - cache.incr('answer1') + self.assertEqual(cache.incr('answer1'), 38) self.assertEqual(cache.get('answer1', version=1), 38) self.assertEqual(cache.get('answer1', version=2), 42) - cache.decr('answer1') + self.assertEqual(cache.decr('answer1'), 37) self.assertEqual(cache.get('answer1', version=1), 37) self.assertEqual(cache.get('answer1', version=2), 42) cache.set('answer2', 37, version=1) cache.set('answer2', 42, version=2) - cache.incr('answer2', version=2) + self.assertEqual(cache.incr('answer2', version=2), 43) self.assertEqual(cache.get('answer2', version=1), 37) self.assertEqual(cache.get('answer2', version=2), 43) - cache.decr('answer2', version=2) + self.assertEqual(cache.decr('answer2', version=2), 42) self.assertEqual(cache.get('answer2', version=1), 37) self.assertEqual(cache.get('answer2', version=2), 42) cache.set('answer3', 37, version=1) cache.set('answer3', 42, version=2) - caches['v2'].incr('answer3') + self.assertEqual(caches['v2'].incr('answer3'), 43) self.assertEqual(cache.get('answer3', version=1), 37) self.assertEqual(cache.get('answer3', version=2), 43) - caches['v2'].decr('answer3') + self.assertEqual(caches['v2'].decr('answer3'), 42) self.assertEqual(cache.get('answer3', version=1), 37) self.assertEqual(cache.get('answer3', version=2), 42) cache.set('answer4', 37, version=1) cache.set('answer4', 42, version=2) - caches['v2'].incr('answer4', version=1) + self.assertEqual(caches['v2'].incr('answer4', version=1), 38) self.assertEqual(cache.get('answer4', version=1), 38) self.assertEqual(cache.get('answer4', version=2), 42) - caches['v2'].decr('answer4', version=1) + self.assertEqual(caches['v2'].decr('answer4', version=1), 37) self.assertEqual(cache.get('answer4', version=1), 37) self.assertEqual(cache.get('answer4', version=2), 42) @@ -790,6 +860,13 @@ def test_incr_version(self): with self.assertRaises(ValueError): cache.incr_version('does_not_exist') + cache.set('null', None) + if self.supports_get_with_default: + self.assertEqual(cache.incr_version('null'), 2) + else: + with self.assertRaises(self.incr_decr_type_error): + cache.incr_version('null') + def test_decr_version(self): cache.set('answer', 42, version=2) self.assertIsNone(cache.get('answer')) @@ -814,6 +891,13 @@ def test_decr_version(self): with self.assertRaises(ValueError): cache.decr_version('does_not_exist', version=2) + cache.set('null', None, version=2) + if self.supports_get_with_default: + self.assertEqual(cache.decr_version('null', version=2), 1) + else: + with self.assertRaises(self.incr_decr_type_error): + cache.decr_version('null', version=2) + def test_custom_key_func(self): # Two caches with different key functions aren't visible to each other cache.set('answer1', 42) @@ -827,30 +911,33 @@ def test_custom_key_func(self): self.assertEqual(caches['custom_key2'].get('answer2'), 42) def test_cache_write_unpicklable_object(self): - update_middleware = UpdateCacheMiddleware() - update_middleware.cache = cache - - fetch_middleware = FetchFromCacheMiddleware() + fetch_middleware = FetchFromCacheMiddleware(empty_response) fetch_middleware.cache = cache request = self.factory.get('/cache/test') request._cache_update_cache = True - get_cache_data = FetchFromCacheMiddleware().process_request(request) + get_cache_data = FetchFromCacheMiddleware( + empty_response + ).process_request(request) self.assertIsNone(get_cache_data) - response = HttpResponse() content = 'Testing cookie serialization.' - response.content = content - response.set_cookie('foo', 'bar') - update_middleware.process_response(request, response) + def get_response(req): + response = HttpResponse(content) + response.set_cookie('foo', 'bar') + return response + + update_middleware = UpdateCacheMiddleware(get_response) + update_middleware.cache = cache + response = update_middleware(request) get_cache_data = fetch_middleware.process_request(request) self.assertIsNotNone(get_cache_data) self.assertEqual(get_cache_data.content, content.encode()) self.assertEqual(get_cache_data.cookies, response.cookies) - update_middleware.process_response(request, get_cache_data) + UpdateCacheMiddleware(lambda req: get_cache_data)(request) get_cache_data = fetch_middleware.process_request(request) self.assertIsNotNone(get_cache_data) self.assertEqual(get_cache_data.content, content.encode()) @@ -869,7 +956,12 @@ def test_get_or_set(self): self.assertIsNone(cache.get('projector')) self.assertEqual(cache.get_or_set('projector', 42), 42) self.assertEqual(cache.get('projector'), 42) - self.assertEqual(cache.get_or_set('null', None), None) + self.assertIsNone(cache.get_or_set('null', None)) + if self.supports_get_with_default: + # Previous get_or_set() stores None in the cache. + self.assertIsNone(cache.get('null', 'default')) + else: + self.assertEqual(cache.get('null', 'default'), 'default') def test_get_or_set_callable(self): def my_callable(): @@ -878,14 +970,16 @@ def my_callable(): self.assertEqual(cache.get_or_set('mykey', my_callable), 'value') self.assertEqual(cache.get_or_set('mykey', my_callable()), 'value') - def test_get_or_set_callable_returning_none(self): - self.assertIsNone(cache.get_or_set('mykey', lambda: None)) - # Previous get_or_set() doesn't store None in the cache. - self.assertEqual(cache.get('mykey', 'default'), 'default') + self.assertIsNone(cache.get_or_set('null', lambda: None)) + if self.supports_get_with_default: + # Previous get_or_set() stores None in the cache. + self.assertIsNone(cache.get('null', 'default')) + else: + self.assertEqual(cache.get('null', 'default'), 'default') def test_get_or_set_version(self): msg = "get_or_set() missing 1 required positional argument: 'default'" - cache.get_or_set('brian', 1979, version=2) + self.assertEqual(cache.get_or_set('brian', 1979, version=2), 1979) with self.assertRaisesMessage(TypeError, msg): cache.get_or_set('brian') with self.assertRaisesMessage(TypeError, msg): @@ -949,6 +1043,11 @@ def test_ignores_non_cache_files(self): ) os.remove(fname) + def test_creates_cache_dir_if_nonexistent(self): + os.rmdir(self.dirname) + cache.set('foo', 'bar') + self.assertTrue(os.path.exists(self.dirname)) + def test_clear_does_not_remove_cache_dir(self): cache.clear() self.assertTrue( @@ -1026,19 +1125,6 @@ def test_pop(self): ) self.assertEqual(cache.pop(4, retry=False), 4) - def test_pickle(self): - letters = 'abcde' - cache.clear() - - for num, val in enumerate(letters): - cache.set(val, num) - - data = pickle.dumps(cache) - other = pickle.loads(data) - - for key in letters: - self.assertEqual(other.get(key), cache.get(key)) - def test_cache(self): subcache = cache.cache('test') directory = os.path.join(cache.directory, 'cache', 'test') From f3836f9f1938ede1eaf32d474be0311d0c7a3183 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:35:55 -0800 Subject: [PATCH 292/329] Fix DjangoCache.delete to return True/False --- diskcache/djangocache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 347a613..8bf85ce 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -220,7 +220,7 @@ def delete(self, key, version=None, retry=True): """ # pylint: disable=arguments-differ key = self.make_key(key, version=version) - self._cache.delete(key, retry) + return self._cache.delete(key, retry) def incr(self, key, delta=1, version=None, default=None, retry=True): """Increment value by delta for item with key. From 8da6634877178ff3704d8468aa5a95b9baf4f9ac Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:37:38 -0800 Subject: [PATCH 293/329] Bump Django testing to 3.2 --- requirements.txt | 2 +- tox.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2ab91c7..efb2160 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -e . blue coverage -django==2.2.* +django==3.2.* django_redis doc8 flake8 diff --git a/tox.ini b/tox.ini index 104a084..650c8f0 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ skip_missing_interpreters=True [testenv] commands=pytest deps= - django==2.2.* + django==3.2.* pytest pytest-cov pytest-django @@ -31,7 +31,7 @@ allowlist_externals=make changedir=docs commands=make html deps= - django==2.2.* + django==3.2.* sphinx [testenv:flake8] @@ -53,7 +53,7 @@ deps=mypy [testenv:pylint] commands=pylint {toxinidir}/diskcache deps= - django==2.2.* + django==3.2.* pylint [testenv:rstcheck] From 221f3d38cea69e33ba9a83cde0e2e202f3d3d70e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:45:26 -0800 Subject: [PATCH 294/329] Remove unused imports --- tests/test_djangocache.py | 38 ++------------------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 36e1b01..6fae5be 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -3,64 +3,30 @@ # Unit tests for cache framework # Uses whatever cache backend is set in the test settings file. -import copy -import io import os import pickle -import re import shutil -import sys import tempfile -import threading import time -import unittest -import warnings -from pathlib import Path -from unittest import mock, skipIf +from unittest import mock from django.conf import settings -from django.core import management, signals from django.core.cache import ( - DEFAULT_CACHE_ALIAS, CacheKeyWarning, - InvalidCacheKey, cache, caches, ) -from django.core.cache.utils import make_template_fragment_key -from django.db import close_old_connections, connection, connections -from django.http import ( - HttpRequest, - HttpResponse, - HttpResponseNotModified, - StreamingHttpResponse, -) +from django.http import HttpResponse from django.middleware.cache import ( - CacheMiddleware, FetchFromCacheMiddleware, UpdateCacheMiddleware, ) -from django.middleware.csrf import CsrfViewMiddleware -from django.template import engines -from django.template.context_processors import csrf -from django.template.response import TemplateResponse from django.test import ( RequestFactory, - SimpleTestCase, TestCase, - TransactionTestCase, override_settings, ) from django.test.signals import setting_changed -from django.utils import timezone, translation -from django.utils.cache import ( - get_cache_key, - learn_cache_key, - patch_cache_control, - patch_vary_headers, -) -from django.utils.encoding import force_text -from django.views.decorators.cache import cache_page ################################################################################ # Setup Django for models import. From 5ac77969c88df42eece83be2e4ce85d2e277109d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:48:08 -0800 Subject: [PATCH 295/329] Run isort --- tests/test_djangocache.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 6fae5be..5f83b81 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -11,21 +11,13 @@ from unittest import mock from django.conf import settings -from django.core.cache import ( - CacheKeyWarning, - cache, - caches, -) +from django.core.cache import CacheKeyWarning, cache, caches from django.http import HttpResponse from django.middleware.cache import ( FetchFromCacheMiddleware, UpdateCacheMiddleware, ) -from django.test import ( - RequestFactory, - TestCase, - override_settings, -) +from django.test import RequestFactory, TestCase, override_settings from django.test.signals import setting_changed ################################################################################ From 1cb1425b1ba24f26fb1e37349c4c2658c2a46d8f Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Thu, 30 Dec 2021 17:58:24 -0800 Subject: [PATCH 296/329] Bump version to 5.4.0 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index c361ca9..2355128 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.3.0' -__build__ = 0x050300 +__version__ = '5.4.0' +__build__ = 0x050400 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2022 Grant Jenks' From c9844bba9de039ab64858073d8864f7e40172c9e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 20 Feb 2022 12:47:41 -0800 Subject: [PATCH 297/329] Put commands above deps for doc8 testenv --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 650c8f0..65da17c 100644 --- a/tox.ini +++ b/tox.ini @@ -23,8 +23,8 @@ commands=blue --check {toxinidir}/setup.py {toxinidir}/diskcache {toxinidir}/tes deps=blue [testenv:doc8] -deps=doc8 commands=doc8 docs --ignore-path docs/_build +deps=doc8 [testenv:docs] allowlist_externals=make From 2d2cc8b39db3e0c0785dd304bde7902219d85ffe Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 20 Feb 2022 13:42:14 -0800 Subject: [PATCH 298/329] Update rsync command for uploading docs --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 65da17c..36ed2b8 100644 --- a/tox.ini +++ b/tox.ini @@ -64,8 +64,9 @@ deps=rstcheck allowlist_externals=rsync changedir=docs commands= - rsync -azP --stats --delete _build/html/ \ - grantjenks.com:/srv/www/www.grantjenks.com/public/docs/diskcache/ + rsync --rsync-path 'sudo -u herokuish rsync' -azP --stats --delete \ + _build/html/ \ + grantjenks:/srv/www/grantjenks.com/public/docs/diskcache/ [isort] multi_line_output = 3 From c1774469b8d4c4906fe24f7b5afd637795af48d9 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:12:28 -0700 Subject: [PATCH 299/329] Remove unused import --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index b49d9c8..841dfb9 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,3 @@ -from io import open - from setuptools import setup from setuptools.command.test import test as TestCommand From f3fcdffee88af7923740b9864d533524a79d1222 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:27:48 -0700 Subject: [PATCH 300/329] Update Cache(...) params when allocating --- diskcache/fanout.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index dc5240c..8fe51d9 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -573,9 +573,11 @@ def reset(self, key, value=ENOVAL): break return result - def cache(self, name): + def cache(self, name, timeout=60, disk=None, **settings): """Return Cache with given `name` in subdirectory. + If disk is none (default), uses the fanout cache disk. + >>> fanout_cache = FanoutCache() >>> cache = fanout_cache.cache('test') >>> cache.set('abc', 123) @@ -588,6 +590,9 @@ def cache(self, name): True :param str name: subdirectory name for Cache + :param float timeout: SQLite connection timeout + :param disk: Disk type or subclass for serialization + :param settings: any of DEFAULT_SETTINGS :return: Cache with given name """ @@ -598,7 +603,12 @@ def cache(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'cache', *parts) - temp = Cache(directory=directory, disk=self._disk) + temp = Cache( + directory=directory, + timeout=timeout, + disk=self._disk if disk is None else Disk, + **settings, + ) _caches[name] = temp return temp @@ -626,7 +636,11 @@ def deque(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'deque', *parts) - cache = Cache(directory=directory, disk=self._disk) + cache = Cache( + directory=directory, + disk=self._disk, + eviction_policy='none', + ) deque = Deque.fromcache(cache) _deques[name] = deque return deque @@ -658,7 +672,11 @@ def index(self, name): except KeyError: parts = name.split('/') directory = op.join(self._directory, 'index', *parts) - cache = Cache(directory=directory, disk=self._disk) + cache = Cache( + directory=directory, + disk=self._disk, + eviction_policy='none', + ) index = Index.fromcache(cache) _indexes[name] = index return index From ee7a248e5c09e6fb9145b2e4a1777a345114b71d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:37:39 -0700 Subject: [PATCH 301/329] Add docs about the eviction policy to recipes --- diskcache/recipes.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/diskcache/recipes.py b/diskcache/recipes.py index b5af6dd..babb68f 100644 --- a/diskcache/recipes.py +++ b/diskcache/recipes.py @@ -17,6 +17,9 @@ class Averager: Sometimes known as "online statistics," the running average maintains the total and count. The average can then be calculated at any time. + Assumes the key will not be evicted. Set the eviction policy to 'none' on + the cache to guarantee the key is not evicted. + >>> import diskcache >>> cache = diskcache.FanoutCache() >>> ave = Averager(cache, 'latency') @@ -65,6 +68,9 @@ def pop(self): class Lock: """Recipe for cross-process and cross-thread lock. + Assumes the key will not be evicted. Set the eviction policy to 'none' on + the cache to guarantee the key is not evicted. + >>> import diskcache >>> cache = diskcache.Cache() >>> lock = Lock(cache, 'report-123') @@ -113,6 +119,9 @@ def __exit__(self, *exc_info): class RLock: """Recipe for cross-process and cross-thread re-entrant lock. + Assumes the key will not be evicted. Set the eviction policy to 'none' on + the cache to guarantee the key is not evicted. + >>> import diskcache >>> cache = diskcache.Cache() >>> rlock = RLock(cache, 'user-123') @@ -181,6 +190,9 @@ def __exit__(self, *exc_info): class BoundedSemaphore: """Recipe for cross-process and cross-thread bounded semaphore. + Assumes the key will not be evicted. Set the eviction policy to 'none' on + the cache to guarantee the key is not evicted. + >>> import diskcache >>> cache = diskcache.Cache() >>> semaphore = BoundedSemaphore(cache, 'max-cons', value=2) @@ -251,6 +263,9 @@ def throttle( ): """Decorator to throttle calls to function. + Assumes keys will not be evicted. Set the eviction policy to 'none' on the + cache to guarantee the keys are not evicted. + >>> import diskcache, time >>> cache = diskcache.Cache() >>> count = 0 @@ -305,6 +320,9 @@ def barrier(cache, lock_factory, name=None, expire=None, tag=None): Supports different kinds of locks: Lock, RLock, BoundedSemaphore. + Assumes keys will not be evicted. Set the eviction policy to 'none' on the + cache to guarantee the keys are not evicted. + >>> import diskcache, time >>> cache = diskcache.Cache() >>> @barrier(cache, Lock) From fb2fa2c401bb88ecbbc58009d02cbe65f2fc594a Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:52:52 -0700 Subject: [PATCH 302/329] Test on Django 4.2 LTS --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 36ed2b8..0ddcc8f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ skip_missing_interpreters=True [testenv] commands=pytest deps= - django==3.2.* + django==4.2.* pytest pytest-cov pytest-django @@ -31,7 +31,7 @@ allowlist_externals=make changedir=docs commands=make html deps= - django==3.2.* + django==4.2.* sphinx [testenv:flake8] @@ -53,7 +53,7 @@ deps=mypy [testenv:pylint] commands=pylint {toxinidir}/diskcache deps= - django==3.2.* + django==4.2.* pylint [testenv:rstcheck] From 0a9783353ff7dc9f874154ef98b23de27e4aba8d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:53:17 -0700 Subject: [PATCH 303/329] Update year to 2023 --- README.rst | 4 ++-- diskcache/__init__.py | 2 +- docs/conf.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index eb06a6a..04abdc0 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ DiskCache: Disk Backed Cache `DiskCache`_ is an Apache2 licensed disk and file backed cache library, written in pure-Python, and compatible with Django. -The cloud-based computing of 2021 puts a premium on memory. Gigabytes of empty +The cloud-based computing of 2023 puts a premium on memory. Gigabytes of empty space is left on disks as processes vie for memory. Among these processes is Memcached (and sometimes Redis) which is used as a cache. Wouldn't it be nice to leverage empty disk space for caching? @@ -387,7 +387,7 @@ Reference License ------- -Copyright 2016-2022 Grant Jenks +Copyright 2016-2023 Grant Jenks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 2355128..f7aa771 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -65,4 +65,4 @@ __build__ = 0x050400 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' -__copyright__ = 'Copyright 2016-2022 Grant Jenks' +__copyright__ = 'Copyright 2016-2023 Grant Jenks' diff --git a/docs/conf.py b/docs/conf.py index 92ce1b9..92bf3ec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'DiskCache' -copyright = '2022, Grant Jenks' +copyright = '2023, Grant Jenks' author = 'Grant Jenks' # The full version, including alpha/beta/rc tags From 712cc1827b29fb8f6a76803d32f87a5fb57f3c6e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 22:56:16 -0700 Subject: [PATCH 304/329] Bump python testing to 3.11 --- .github/workflows/integration.yml | 4 ++-- .github/workflows/release.yml | 2 +- tox.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 07a5650..2d83ad3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | pip install --upgrade pip @@ -32,7 +32,7 @@ jobs: max-parallel: 8 matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9, '3.10'] + python-version: [3.7, 3.8, 3.9, '3.10', 3.11] steps: - name: Set up Python ${{ matrix.python-version }} x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f89c14..676593c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | diff --git a/tox.ini b/tox.ini index 0ddcc8f..3735ebc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=bluecheck,doc8,docs,isortcheck,flake8,mypy,pylint,rstcheck,py36,py37,py38,py39 +envlist=bluecheck,doc8,docs,isortcheck,flake8,mypy,pylint,rstcheck,py37,py38,py39,py310,py311 skip_missing_interpreters=True [testenv] From d7ae0990b240e8ea22c1a4060f58900edd339a18 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:07:29 -0700 Subject: [PATCH 305/329] i blue it --- diskcache/core.py | 10 ++--- diskcache/fanout.py | 2 +- tests/benchmark_glob.py | 2 +- tests/settings.py | 2 +- tests/stress_test_core.py | 30 ++++++------- tests/stress_test_fanout.py | 30 ++++++------- tests/test_core.py | 84 ++++++++++++++++++------------------- tests/test_djangocache.py | 2 +- tests/test_fanout.py | 16 +++---- 9 files changed, 85 insertions(+), 93 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index fb343be..05a0854 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -49,14 +49,14 @@ def __repr__(self): 'statistics': 0, # False 'tag_index': 0, # False 'eviction_policy': 'least-recently-stored', - 'size_limit': 2 ** 30, # 1gb + 'size_limit': 2**30, # 1gb 'cull_limit': 10, 'sqlite_auto_vacuum': 1, # FULL - 'sqlite_cache_size': 2 ** 13, # 8,192 pages + 'sqlite_cache_size': 2**13, # 8,192 pages 'sqlite_journal_mode': 'wal', - 'sqlite_mmap_size': 2 ** 26, # 64mb + 'sqlite_mmap_size': 2**26, # 64mb 'sqlite_synchronous': 1, # NORMAL - 'disk_min_file_size': 2 ** 15, # 32kb + 'disk_min_file_size': 2**15, # 32kb 'disk_pickle_protocol': pickle.HIGHEST_PROTOCOL, } @@ -212,7 +212,7 @@ def store(self, value, read, key=UNKNOWN): size = op.getsize(full_path) return size, MODE_TEXT, filename, None elif read: - reader = ft.partial(value.read, 2 ** 22) + reader = ft.partial(value.read, 2**22) filename, full_path = self.filename(key, value) iterator = iter(reader, b'') size = self._write(full_path, iterator, 'xb') diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 8fe51d9..5283490 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -45,7 +45,7 @@ def __init__( timeout=timeout, disk=disk, size_limit=size_limit, - **settings + **settings, ) for num in range(shards) ) diff --git a/tests/benchmark_glob.py b/tests/benchmark_glob.py index 7f0bf7c..7da5fd3 100644 --- a/tests/benchmark_glob.py +++ b/tests/benchmark_glob.py @@ -22,7 +22,7 @@ print(template % ('Count', 'Time')) print(' '.join(['=' * size] * len(cols))) -for count in [10 ** exp for exp in range(6)]: +for count in [10**exp for exp in range(6)]: for value in range(count): with open(op.join('tmp', '%s.tmp' % value), 'wb') as writer: pass diff --git a/tests/settings.py b/tests/settings.py index 1a2f569..04aee85 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -25,7 +25,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [u'testserver'] +ALLOWED_HOSTS = ['testserver'] # Application definition diff --git a/tests/stress_test_core.py b/tests/stress_test_core.py index c30fa3f..2b2578b 100644 --- a/tests/stress_test_core.py +++ b/tests/stress_test_core.py @@ -33,16 +33,14 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) - ) + word = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz', word_size)) size = random.randint(1, int(200 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + word = ''.join( + random.sample('abcdefghijklmnopqrstuvwxyz', word_size) ).encode('utf-8') size = random.randint(1, int(200 / 13)) return word * size @@ -77,18 +75,16 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) - ) - size = random.randint(1, int(2 ** 16 / 13)) + word = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz', word_size)) + size = random.randint(1, int(2**16 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + word = ''.join( + random.sample('abcdefghijklmnopqrstuvwxyz', word_size) ).encode('utf-8') - size = random.randint(1, int(2 ** 16 / 13)) + size = random.randint(1, int(2**16 / 13)) return word * size def make_float(): @@ -233,7 +229,7 @@ def percentile(sequence, percent): def stress_test( create=True, delete=True, - eviction_policy=u'least-recently-stored', + eviction_policy='least-recently-stored', processes=1, threads=1, ): @@ -293,17 +289,17 @@ def stress_test( def stress_test_lru(): """Stress test least-recently-used eviction policy.""" - stress_test(eviction_policy=u'least-recently-used') + stress_test(eviction_policy='least-recently-used') def stress_test_lfu(): """Stress test least-frequently-used eviction policy.""" - stress_test(eviction_policy=u'least-frequently-used') + stress_test(eviction_policy='least-frequently-used') def stress_test_none(): """Stress test 'none' eviction policy.""" - stress_test(eviction_policy=u'none') + stress_test(eviction_policy='none') def stress_test_mp(): @@ -396,7 +392,7 @@ def stress_test_mp(): '-v', '--eviction-policy', type=str, - default=u'least-recently-stored', + default='least-recently-stored', ) args = parser.parse_args() diff --git a/tests/stress_test_fanout.py b/tests/stress_test_fanout.py index d3b67e3..e78dda5 100644 --- a/tests/stress_test_fanout.py +++ b/tests/stress_test_fanout.py @@ -32,16 +32,14 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) - ) + word = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz', word_size)) size = random.randint(1, int(200 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + word = ''.join( + random.sample('abcdefghijklmnopqrstuvwxyz', word_size) ).encode('utf-8') size = random.randint(1, int(200 / 13)) return word * size @@ -76,18 +74,16 @@ def make_long(): def make_unicode(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) - ) - size = random.randint(1, int(2 ** 16 / 13)) + word = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz', word_size)) + size = random.randint(1, int(2**16 / 13)) return word * size def make_bytes(): word_size = random.randint(1, 26) - word = u''.join( - random.sample(u'abcdefghijklmnopqrstuvwxyz', word_size) + word = ''.join( + random.sample('abcdefghijklmnopqrstuvwxyz', word_size) ).encode('utf-8') - size = random.randint(1, int(2 ** 16 / 13)) + size = random.randint(1, int(2**16 / 13)) return word * size def make_float(): @@ -224,7 +220,7 @@ def percentile(sequence, percent): def stress_test( create=True, delete=True, - eviction_policy=u'least-recently-stored', + eviction_policy='least-recently-stored', processes=1, threads=1, ): @@ -284,17 +280,17 @@ def stress_test( def stress_test_lru(): """Stress test least-recently-used eviction policy.""" - stress_test(eviction_policy=u'least-recently-used') + stress_test(eviction_policy='least-recently-used') def stress_test_lfu(): """Stress test least-frequently-used eviction policy.""" - stress_test(eviction_policy=u'least-frequently-used') + stress_test(eviction_policy='least-frequently-used') def stress_test_none(): """Stress test 'none' eviction policy.""" - stress_test(eviction_policy=u'none') + stress_test(eviction_policy='none') def stress_test_mp(): @@ -387,7 +383,7 @@ def stress_test_mp(): '-v', '--eviction-policy', type=str, - default=u'least-recently-stored', + default='least-recently-stored', ) args = parser.parse_args() diff --git a/tests/test_core.py b/tests/test_core.py index 55ca962..356d104 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -38,11 +38,11 @@ def test_init(cache): def test_init_disk(): - with dc.Cache(disk_pickle_protocol=1, disk_min_file_size=2 ** 20) as cache: + with dc.Cache(disk_pickle_protocol=1, disk_min_file_size=2**20) as cache: key = (None, 0, 'abc') cache[key] = 0 cache.check() - assert cache.disk_min_file_size == 2 ** 20 + assert cache.disk_min_file_size == 2**20 assert cache.disk_pickle_protocol == 1 shutil.rmtree(cache.directory, ignore_errors=True) @@ -59,15 +59,15 @@ def test_disk_reset(): assert cache._disk.min_file_size == 0 assert cache._disk.pickle_protocol == 0 - cache.reset('disk_min_file_size', 2 ** 10) + cache.reset('disk_min_file_size', 2**10) cache.reset('disk_pickle_protocol', 2) cache[1] = value cache.check() - assert cache.disk_min_file_size == 2 ** 10 + assert cache.disk_min_file_size == 2**10 assert cache.disk_pickle_protocol == 2 - assert cache._disk.min_file_size == 2 ** 10 + assert cache._disk.min_file_size == 2**10 assert cache._disk.pickle_protocol == 2 shutil.rmtree(cache.directory, ignore_errors=True) @@ -150,7 +150,7 @@ def test_pragma_error(cache): cursor.fetchall = fetchall fetchall.side_effect = [sqlite3.OperationalError] * 60000 - size = 2 ** 28 + size = 2**28 with mock.patch('time.sleep', lambda num: 0): with mock.patch.object(cache, '_local', local): @@ -177,15 +177,15 @@ def __getattr__(self, name): def test_getsetdel(cache): values = [ (None, False), - ((None,) * 2 ** 20, False), + ((None,) * 2**20, False), (1234, False), - (2 ** 512, False), + (2**512, False), (56.78, False), - (u'hello', False), - (u'hello' * 2 ** 20, False), + ('hello', False), + ('hello' * 2**20, False), (b'world', False), - (b'world' * 2 ** 20, False), - (io.BytesIO(b'world' * 2 ** 20), True), + (b'world' * 2**20, False), + (io.BytesIO(b'world' * 2**20), True), ] for key, (value, file_like) in enumerate(values): @@ -229,7 +229,7 @@ def test_get_keyerror4(cache): func = mock.Mock(side_effect=IOError(errno.ENOENT, '')) cache.reset('statistics', True) - cache[0] = b'abcd' * 2 ** 20 + cache[0] = b'abcd' * 2**20 with mock.patch('diskcache.core.open', func): with pytest.raises((IOError, KeyError, OSError)): @@ -237,7 +237,7 @@ def test_get_keyerror4(cache): def test_read(cache): - cache.set(0, b'abcd' * 2 ** 20) + cache.set(0, b'abcd' * 2**20) with cache.read(0) as reader: assert reader is not None @@ -249,7 +249,7 @@ def test_read_keyerror(cache): def test_set_twice(cache): - large_value = b'abcd' * 2 ** 20 + large_value = b'abcd' * 2**20 cache[0] = 0 cache[0] = 1 @@ -283,7 +283,7 @@ def test_set_timeout(cache): with pytest.raises(dc.Timeout): try: with mock.patch.object(cache, '_local', local): - cache.set('a', 'b' * 2 ** 20) + cache.set('a', 'b' * 2**20) finally: cache.check() @@ -299,11 +299,11 @@ def test_get(cache): assert cache.get(2, {}) == {} assert cache.get(0, expire_time=True, tag=True) == (None, None, None) - assert cache.set(0, 0, expire=None, tag=u'number') + assert cache.set(0, 0, expire=None, tag='number') assert cache.get(0, expire_time=True) == (0, None) - assert cache.get(0, tag=True) == (0, u'number') - assert cache.get(0, expire_time=True, tag=True) == (0, None, u'number') + assert cache.get(0, tag=True) == (0, 'number') + assert cache.get(0, expire_time=True, tag=True) == (0, None, 'number') def test_get_expired_fast_path(cache): @@ -359,8 +359,8 @@ def test_pop(cache): assert cache.set('delta', 210) assert cache.pop('delta', expire_time=True) == (210, None) - assert cache.set('epsilon', '0' * 2 ** 20) - assert cache.pop('epsilon') == '0' * 2 ** 20 + assert cache.set('epsilon', '0' * 2**20) + assert cache.pop('epsilon') == '0' * 2**20 def test_pop_ioerror(cache): @@ -426,11 +426,11 @@ def test_stats(cache): def test_path(cache): - cache[0] = u'abc' - large_value = b'abc' * 2 ** 20 + cache[0] = 'abc' + large_value = b'abc' * 2**20 cache[1] = large_value - assert cache.get(0, read=True) == u'abc' + assert cache.get(0, read=True) == 'abc' with cache.get(1, read=True) as reader: assert reader.name is not None @@ -465,7 +465,7 @@ def test_expire_rows(cache): def test_least_recently_stored(cache): - cache.reset('eviction_policy', u'least-recently-stored') + cache.reset('eviction_policy', 'least-recently-stored') cache.reset('size_limit', int(10.1e6)) cache.reset('cull_limit', 2) @@ -500,7 +500,7 @@ def test_least_recently_stored(cache): def test_least_recently_used(cache): - cache.reset('eviction_policy', u'least-recently-used') + cache.reset('eviction_policy', 'least-recently-used') cache.reset('size_limit', int(10.1e6)) cache.reset('cull_limit', 5) @@ -530,7 +530,7 @@ def test_least_recently_used(cache): def test_least_frequently_used(cache): - cache.reset('eviction_policy', u'least-frequently-used') + cache.reset('eviction_policy', 'least-frequently-used') cache.reset('size_limit', int(10.1e6)) cache.reset('cull_limit', 5) @@ -558,8 +558,8 @@ def test_least_frequently_used(cache): def test_check(cache): - blob = b'a' * 2 ** 20 - keys = (0, 1, 1234, 56.78, u'hello', b'world', None) + blob = b'a' * 2**20 + keys = (0, 1, 1234, 56.78, 'hello', b'world', None) for key in keys: cache[key] = blob @@ -662,12 +662,12 @@ def test_clear_timeout(cache): def test_tag(cache): - assert cache.set(0, None, tag=u'zero') + assert cache.set(0, None, tag='zero') assert cache.set(1, None, tag=1234) assert cache.set(2, None, tag=5.67) assert cache.set(3, None, tag=b'three') - assert cache.get(0, tag=True) == (None, u'zero') + assert cache.get(0, tag=True) == (None, 'zero') assert cache.get(1, tag=True) == (None, 1234) assert cache.get(2, tag=True) == (None, 5.67) assert cache.get(3, tag=True) == (None, b'three') @@ -675,11 +675,11 @@ def test_tag(cache): def test_with(cache): with dc.Cache(cache.directory) as tmp: - tmp[u'a'] = 0 - tmp[u'b'] = 1 + tmp['a'] = 0 + tmp['b'] = 1 - assert cache[u'a'] == 0 - assert cache[u'b'] == 1 + assert cache['a'] == 0 + assert cache['b'] == 1 def test_contains(cache): @@ -708,7 +708,7 @@ def test_add(cache): def test_add_large_value(cache): - value = b'abcd' * 2 ** 20 + value = b'abcd' * 2**20 assert cache.add(b'test-key', value) assert cache.get(b'test-key') == value assert not cache.add(b'test-key', value * 2) @@ -919,7 +919,7 @@ def test_push_peek_expire(cache): def test_push_pull_large_value(cache): - value = b'test' * (2 ** 20) + value = b'test' * (2**20) cache.push(value) assert cache.pull() == (500000000000000, value) assert len(cache) == 0 @@ -927,7 +927,7 @@ def test_push_pull_large_value(cache): def test_push_peek_large_value(cache): - value = b'test' * (2 ** 20) + value = b'test' * (2**20) cache.push(value) assert cache.peek() == (500000000000000, value) assert len(cache) == 1 @@ -1144,8 +1144,8 @@ def test_cull_timeout(cache): def test_key_roundtrip(cache): - key_part_0 = u'part0' - key_part_1 = u'part1' + key_part_0 = 'part0' + key_part_1 = 'part1' to_test = [ (key_part_0, key_part_1), [key_part_0, key_part_1], @@ -1354,7 +1354,7 @@ def foo(*args, **kwargs): def test_cleanup_dirs(cache): - value = b'\0' * 2 ** 20 + value = b'\0' * 2**20 start_count = len(os.listdir(cache.directory)) for i in range(10): cache[i] = value @@ -1370,7 +1370,7 @@ 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 + cache[0] = '\0' * 2**20 def test_memoize_ignore(cache): diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index 5f83b81..b5cc2a8 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -1033,7 +1033,7 @@ def test_directory(self): self.assertTrue('tmp' in cache.directory) def test_read(self): - value = b'abcd' * 2 ** 20 + value = b'abcd' * 2**20 result = cache.set(b'test-key', value) self.assertTrue(result) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index f212fac..deea03f 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -34,7 +34,7 @@ def test_init(cache): del default_settings['size_limit'] for key, value in default_settings.items(): assert getattr(cache, key) == value - assert cache.size_limit == 2 ** 27 + assert cache.size_limit == 2**27 cache.check() @@ -229,15 +229,15 @@ def test_incr_concurrent(): def test_getsetdel(cache): values = [ (None, False), - ((None,) * 2 ** 10, False), + ((None,) * 2**10, False), (1234, False), - (2 ** 512, False), + (2**512, False), (56.78, False), - (u'hello', False), - (u'hello' * 2 ** 10, False), + ('hello', False), + ('hello' * 2**10, False), (b'world', False), - (b'world' * 2 ** 10, False), - (io.BytesIO(b'world' * 2 ** 10), True), + (b'world' * 2**10, False), + (io.BytesIO(b'world' * 2**10), True), ] for key, (value, file_like) in enumerate(values): @@ -341,7 +341,7 @@ def test_tag_index(cache): def test_read(cache): - cache.set(0, b'abcd' * 2 ** 20) + cache.set(0, b'abcd' * 2**20) with cache.read(0) as reader: assert reader is not None From a53283d554fbe7bd678067f24d6f45d57de3f83b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:25:16 -0700 Subject: [PATCH 306/329] Update requirements --- requirements-dev.txt | 23 +++++++++++++++++++++++ requirements.txt | 22 ---------------------- 2 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6149361 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,23 @@ +-e . +blue +coverage +django==4.2.* +django_redis +doc8 +flake8 +ipython +jedi +pickleDB +pylibmc +pylint +pytest +pytest-cov +pytest-django +pytest-env +pytest-xdist +rstcheck +sphinx +sqlitedict +tox +twine +wheel diff --git a/requirements.txt b/requirements.txt index efb2160..d6e1198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1 @@ -e . -blue -coverage -django==3.2.* -django_redis -doc8 -flake8 -ipython -jedi==0.17.* # Remove after IPython bug fixed. -pickleDB -pylibmc -pylint -pytest -pytest-cov -pytest-django -pytest-env -pytest-xdist -rstcheck -sphinx -sqlitedict -tox -twine -wheel From b22a7d58c3dbf3f71fa4f9156ccbd4892ebf3fe2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:25:35 -0700 Subject: [PATCH 307/329] Update pylint --- .pylintrc | 829 ++++++++++++++++++++++++---------------------- diskcache/core.py | 6 +- 2 files changed, 439 insertions(+), 396 deletions(-) diff --git a/.pylintrc b/.pylintrc index 6baa978..dc1490a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,27 +1,77 @@ -[MASTER] +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) extension-pkg-whitelist= -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10.0 +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= -# Add files or directories to the blacklist. They should be base names, not -# paths. +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. ignore=CVS -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. jobs=1 # Control the amount of potential inferred values when inferring a single @@ -36,6 +86,19 @@ load-plugins= # Pickle collected data for later comparisons. persistent=yes +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.11 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes @@ -44,321 +107,8 @@ suggestion-mode=yes # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape, - no-member, - no-else-return, - duplicate-code, - inconsistent-return-statements, - consider-using-f-string, - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'error', 'warning', 'refactor', and 'convention' -# which contain the number of messages in each category, as well as 'statement' -# which is the total number of statements analyzed. This score is used by the -# global evaluation report (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it work, -# install the python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -#notes-rgx= - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=3000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=4 +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= [BASIC] @@ -367,13 +117,15 @@ min-similarity-lines=4 argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- -# naming-style. +# naming-style. If left empty, argument names will be checked with the set +# naming style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming # style. #attr-rgx= @@ -393,20 +145,30 @@ bad-names-rgxs= class-attribute-naming-style=any # Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. #class-attribute-rgx= +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- -# style. +# style. If left empty, class names will be checked with the set naming style. #class-rgx= # Naming style matching correct constant names. const-naming-style=UPPER_CASE # Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming # style. #const-rgx= @@ -418,7 +180,8 @@ docstring-min-length=-1 function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- -# naming-style. +# naming-style. If left empty, function names will be checked with the set +# naming style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. @@ -440,21 +203,22 @@ include-naming-hint=no inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- -# style. +# style. If left empty, method names will be checked with the set naming style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- -# style. +# style. If left empty, module names will be checked with the set naming style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when @@ -470,90 +234,56 @@ no-docstring-rgx=^_ # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- -# naming-style. +# naming-style. If left empty, variable names will be checked with the set +# naming style. #variable-rgx= -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no +[CLASSES] -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[CLASSES] +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp, + asyncSetUp, __post_init__ # List of member names, which should be excluded from the protected access # warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls +valid-metaclass-classmethod-first-arg=mcs [DESIGN] +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + # Maximum number of arguments for function / method. max-args=8 @@ -587,7 +317,320 @@ min-public-methods=2 [EXCEPTIONS] -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=2500 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + consider-using-f-string, + no-member, + no-else-return, + no-else-raise, + inconsistent-return-statements + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=20 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/diskcache/core.py b/diskcache/core.py index 05a0854..9f3a597 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -170,7 +170,7 @@ def get(self, key, raw): :return: corresponding Python key """ - # pylint: disable=no-self-use,unidiomatic-typecheck + # pylint: disable=unidiomatic-typecheck if raw: return bytes(key) if type(key) is sqlite3.Binary else key else: @@ -228,7 +228,6 @@ def store(self, value, read, key=UNKNOWN): return len(result), MODE_PICKLE, filename, None def _write(self, full_path, iterator, mode, encoding=None): - # pylint: disable=no-self-use full_dir, _ = op.split(full_path) for count in range(1, 11): @@ -264,7 +263,7 @@ def fetch(self, mode, filename, value, read): :raises: IOError if the value cannot be read """ - # pylint: disable=no-self-use,unidiomatic-typecheck,consider-using-with + # pylint: disable=unidiomatic-typecheck,consider-using-with if mode == MODE_RAW: return bytes(value) if type(value) is sqlite3.Binary else value elif mode == MODE_BINARY: @@ -1378,6 +1377,7 @@ def delete(self, key, retry=False): :raises Timeout: if database timeout occurs """ + # pylint: disable=unnecessary-dunder-call try: return self.__delitem__(key, retry=retry) except KeyError: From 471aa5e551aed186dd34d5bc7d345be835efd6f3 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:26:38 -0700 Subject: [PATCH 308/329] Drop Python 3.7 from testing --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 3735ebc..e7217a7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=bluecheck,doc8,docs,isortcheck,flake8,mypy,pylint,rstcheck,py37,py38,py39,py310,py311 +envlist=bluecheck,doc8,docs,isortcheck,flake8,mypy,pylint,rstcheck,py38,py39,py310,py311 skip_missing_interpreters=True [testenv] From c14345f105f14eba45986b09ec96d87b8997c9cc Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:42:38 -0700 Subject: [PATCH 309/329] Update tests for Django 4.2 --- tests/test_djangocache.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_djangocache.py b/tests/test_djangocache.py index b5cc2a8..734ba1b 100644 --- a/tests/test_djangocache.py +++ b/tests/test_djangocache.py @@ -870,7 +870,6 @@ def test_custom_key_func(self): def test_cache_write_unpicklable_object(self): fetch_middleware = FetchFromCacheMiddleware(empty_response) - fetch_middleware.cache = cache request = self.factory.get('/cache/test') request._cache_update_cache = True @@ -887,7 +886,6 @@ def get_response(req): return response update_middleware = UpdateCacheMiddleware(get_response) - update_middleware.cache = cache response = update_middleware(request) get_cache_data = fetch_middleware.process_request(request) From 0294d58cd0cb72dd6affbbc46b3ff05d96d015cc Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:43:15 -0700 Subject: [PATCH 310/329] Bump version to v5.5.0 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index f7aa771..8d7e28c 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.4.0' -__build__ = 0x050400 +__version__ = '5.5.0' +__build__ = 0x050500 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From 6cd6888a16be1d531a19ca91d5e0daa6edac9718 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:45:14 -0700 Subject: [PATCH 311/329] Drop 3.7 from CI --- .github/workflows/integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2d83ad3..07aceec 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -32,7 +32,7 @@ jobs: max-parallel: 8 matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, '3.10', 3.11] + python-version: [3.8, 3.9, '3.10', 3.11] steps: - name: Set up Python ${{ matrix.python-version }} x64 From bbac13b54f9ac0a5c45b7cfbd242869b41042cac Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:52:57 -0700 Subject: [PATCH 312/329] Install dev requirements for wheel package --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 676593c..21b6e5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Create source dist run: python setup.py sdist From 0f5d8ed63406e26de96701f98aa585b4fb26f6dd Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 10 Apr 2023 23:53:20 -0700 Subject: [PATCH 313/329] Bump version to 5.5.1 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 8d7e28c..95dafb3 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.5.0' -__build__ = 0x050500 +__version__ = '5.5.1' +__build__ = 0x050501 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From fe5ee43ac5df1847556f280c696ab921f0910be2 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 16 Apr 2023 21:42:13 -0700 Subject: [PATCH 314/329] Close the cache explicitly before deleting the reference --- diskcache/persistent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index c3d570b..cce3736 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -533,12 +533,13 @@ def reverse(self): # GrantJ 2019-03-22 Consider using an algorithm that swaps the values # at two keys. Like self._cache.swap(key1, key2, retry=True) The swap # method would exchange the values at two given keys. Then, using a - # forward iterator and a reverse iterator, the reversis method could + # forward iterator and a reverse iterator, the reverse method could # avoid making copies of the values. temp = Deque(iterable=reversed(self)) self.clear() self.extend(temp) directory = temp.directory + temp.close() del temp rmtree(directory) From 9380c784d9e2954611b2ea309f1c657602085f25 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Sun, 16 Apr 2023 23:02:24 -0700 Subject: [PATCH 315/329] Oops, close the cache, not the deque --- diskcache/persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index cce3736..01cf4e3 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -539,7 +539,7 @@ def reverse(self): self.clear() self.extend(temp) directory = temp.directory - temp.close() + temp._cache.close() del temp rmtree(directory) From f5a17ff0959a4cc7147d45a4cc8d8eef8d7416b0 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 19:00:34 -0700 Subject: [PATCH 316/329] Shutup pylint --- diskcache/persistent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/diskcache/persistent.py b/diskcache/persistent.py index 01cf4e3..c3f22b5 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -530,6 +530,7 @@ def reverse(self): ['c', 'b', 'a'] """ + # pylint: disable=protected-access # GrantJ 2019-03-22 Consider using an algorithm that swaps the values # at two keys. Like self._cache.swap(key1, key2, retry=True) The swap # method would exchange the values at two given keys. Then, using a From ef94856d2447fa9662bc62557f9b96ba6f131e15 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 19:01:25 -0700 Subject: [PATCH 317/329] Bump version to 5.5.2 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 95dafb3..134c88a 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.5.1' -__build__ = 0x050501 +__version__ = '5.5.2' +__build__ = 0x050502 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From 74e554c5d9340765f6fd6f7891da49a420e92b70 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 19:05:01 -0700 Subject: [PATCH 318/329] Bump versions of checkout and setup-python --- .github/workflows/integration.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 07aceec..b596fc6 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -12,9 +12,9 @@ jobs: check: [bluecheck, doc8, docs, flake8, isortcheck, mypy, pylint, rstcheck] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install dependencies @@ -36,12 +36,12 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} x64 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: x64 - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install tox run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21b6e5b..efe73c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install libmemcached-dev run: | @@ -19,7 +19,7 @@ jobs: sudo apt-get install libmemcached-dev - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.11' From 35dbeabd283b242e9afd33713a5cea5cd260f51d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 20:38:04 -0700 Subject: [PATCH 319/329] Add maxlen parameter to diskcache.Deque (#191) * Add maxlen parameter to diskcache.Deque --- diskcache/djangocache.py | 5 ++- diskcache/fanout.py | 5 ++- diskcache/persistent.py | 91 +++++++++++++++++++++++++++++++--------- docs/tutorial.rst | 3 ++ tests/test_deque.py | 27 +++++++++++- 5 files changed, 107 insertions(+), 24 deletions(-) diff --git a/diskcache/djangocache.py b/diskcache/djangocache.py index 8bf85ce..5dc8ce2 100644 --- a/diskcache/djangocache.py +++ b/diskcache/djangocache.py @@ -44,14 +44,15 @@ def cache(self, name): """ return self._cache.cache(name) - def deque(self, name): + def deque(self, name, maxlen=None): """Return Deque with given `name` in subdirectory. :param str name: subdirectory name for Deque + :param maxlen: max length (default None, no max) :return: Deque with given name """ - return self._cache.deque(name) + return self._cache.deque(name, maxlen=maxlen) def index(self, name): """Return Index with given `name` in subdirectory. diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 5283490..50005fc 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -612,7 +612,7 @@ def cache(self, name, timeout=60, disk=None, **settings): _caches[name] = temp return temp - def deque(self, name): + def deque(self, name, maxlen=None): """Return Deque with given `name` in subdirectory. >>> cache = FanoutCache() @@ -626,6 +626,7 @@ def deque(self, name): 1 :param str name: subdirectory name for Deque + :param maxlen: max length (default None, no max) :return: Deque with given name """ @@ -641,7 +642,7 @@ def deque(self, name): disk=self._disk, eviction_policy='none', ) - deque = Deque.fromcache(cache) + deque = Deque.fromcache(cache, maxlen=maxlen) _deques[name] = deque return deque diff --git a/diskcache/persistent.py b/diskcache/persistent.py index c3f22b5..522bb74 100644 --- a/diskcache/persistent.py +++ b/diskcache/persistent.py @@ -75,7 +75,7 @@ class Deque(Sequence): """ - def __init__(self, iterable=(), directory=None): + def __init__(self, iterable=(), directory=None, maxlen=None): """Initialize deque instance. If directory is None then temporary directory created. The directory @@ -86,10 +86,11 @@ def __init__(self, iterable=(), directory=None): """ self._cache = Cache(directory, eviction_policy='none') - self.extend(iterable) + self._maxlen = float('inf') if maxlen is None else maxlen + self._extend(iterable) @classmethod - def fromcache(cls, cache, iterable=()): + def fromcache(cls, cache, iterable=(), maxlen=None): """Initialize deque using `cache`. >>> cache = Cache() @@ -111,7 +112,8 @@ def fromcache(cls, cache, iterable=()): # pylint: disable=no-member,protected-access self = cls.__new__(cls) self._cache = cache - self.extend(iterable) + self._maxlen = float('inf') if maxlen is None else maxlen + self._extend(iterable) return self @property @@ -124,6 +126,31 @@ def directory(self): """Directory path where deque is stored.""" return self._cache.directory + @property + def maxlen(self): + """Max length of the deque.""" + return self._maxlen + + @maxlen.setter + def maxlen(self, value): + """Set max length of the deque. + + Pops items from left while length greater than max. + + >>> deque = Deque() + >>> deque.extendleft('abcde') + >>> deque.maxlen = 3 + >>> list(deque) + ['c', 'd', 'e'] + + :param value: max length + + """ + self._maxlen = value + with self._cache.transact(retry=True): + while len(self._cache) > self._maxlen: + self._popleft() + def _index(self, index, func): len_self = len(self) @@ -244,7 +271,7 @@ def __iadd__(self, iterable): :return: deque with added items """ - self.extend(iterable) + self._extend(iterable) return self def __iter__(self): @@ -292,10 +319,11 @@ def __reversed__(self): pass def __getstate__(self): - return self.directory + return self.directory, self.maxlen def __setstate__(self, state): - self.__init__(directory=state) + directory, maxlen = state + self.__init__(directory=directory, maxlen=maxlen) def append(self, value): """Add `value` to back of deque. @@ -310,7 +338,12 @@ def append(self, value): :param value: value to add to back of deque """ - self._cache.push(value, retry=True) + with self._cache.transact(retry=True): + self._cache.push(value, retry=True) + if len(self._cache) > self._maxlen: + self._popleft() + + _append = append def appendleft(self, value): """Add `value` to front of deque. @@ -325,7 +358,12 @@ def appendleft(self, value): :param value: value to add to front of deque """ - self._cache.push(value, side='front', retry=True) + with self._cache.transact(retry=True): + self._cache.push(value, side='front', retry=True) + if len(self._cache) > self._maxlen: + self._pop() + + _appendleft = appendleft def clear(self): """Remove all elements from deque. @@ -340,6 +378,13 @@ def clear(self): """ self._cache.clear(retry=True) + _clear = clear + + def copy(self): + """Copy deque with same directory and max length.""" + TypeSelf = type(self) + return TypeSelf(directory=self.directory, maxlen=self.maxlen) + def count(self, value): """Return number of occurrences of `value` in deque. @@ -365,7 +410,9 @@ def extend(self, iterable): """ for value in iterable: - self.append(value) + self._append(value) + + _extend = extend def extendleft(self, iterable): """Extend front side of deque with value from `iterable`. @@ -379,7 +426,7 @@ def extendleft(self, iterable): """ for value in iterable: - self.appendleft(value) + self._appendleft(value) def peek(self): """Peek at value at back of deque. @@ -459,6 +506,8 @@ def pop(self): raise IndexError('pop from an empty deque') return value + _pop = pop + def popleft(self): """Remove and return value at front of deque. @@ -483,6 +532,8 @@ def popleft(self): raise IndexError('pop from an empty deque') return value + _popleft = popleft + def remove(self, value): """Remove first occurrence of `value` in deque. @@ -537,8 +588,8 @@ def reverse(self): # forward iterator and a reverse iterator, the reverse method could # avoid making copies of the values. temp = Deque(iterable=reversed(self)) - self.clear() - self.extend(temp) + self._clear() + self._extend(temp) directory = temp.directory temp._cache.close() del temp @@ -575,22 +626,22 @@ def rotate(self, steps=1): for _ in range(steps): try: - value = self.pop() + value = self._pop() except IndexError: return else: - self.appendleft(value) + self._appendleft(value) else: steps *= -1 steps %= len_self for _ in range(steps): try: - value = self.popleft() + value = self._popleft() except IndexError: return else: - self.append(value) + self._append(value) __hash__ = None # type: ignore @@ -669,7 +720,9 @@ def __init__(self, *args, **kwargs): args = args[1:] directory = None self._cache = Cache(directory, eviction_policy='none') - self.update(*args, **kwargs) + self._update(*args, **kwargs) + + _update = MutableMapping.update @classmethod def fromcache(cls, cache, *args, **kwargs): @@ -695,7 +748,7 @@ def fromcache(cls, cache, *args, **kwargs): # pylint: disable=no-member,protected-access self = cls.__new__(cls) self._cache = cache - self.update(*args, **kwargs) + self._update(*args, **kwargs) return self @property diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 1963635..69277d3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -565,6 +565,9 @@ access and editing at both front and back sides. :class:`Deque 4 >>> other.popleft() 'foo' + >>> thing = Deque('abcde', maxlen=3) + >>> list(thing) + ['c', 'd', 'e'] :class:`Deque ` objects provide an efficient and safe means of cross-thread and cross-process communication. :class:`Deque ` diff --git a/tests/test_deque.py b/tests/test_deque.py index add7714..71c69e2 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -77,6 +77,20 @@ def test_getsetdel(deque): assert len(deque) == 0 +def test_append(deque): + deque.maxlen = 3 + for item in 'abcde': + deque.append(item) + assert deque == 'cde' + + +def test_appendleft(deque): + deque.maxlen = 3 + for item in 'abcde': + deque.appendleft(item) + assert deque == 'edc' + + def test_index_positive(deque): cache = mock.MagicMock() cache.__len__.return_value = 3 @@ -131,9 +145,12 @@ def test_state(deque): sequence = list('abcde') deque.extend(sequence) assert deque == sequence + deque.maxlen = 3 + assert list(deque) == sequence[-3:] state = pickle.dumps(deque) values = pickle.loads(state) - assert values == sequence + assert values == sequence[-3:] + assert values.maxlen == 3 def test_compare(deque): @@ -161,6 +178,14 @@ def test_repr(): assert repr(deque) == 'Deque(directory=%r)' % directory +def test_copy(deque): + sequence = list('abcde') + deque.extend(sequence) + temp = deque.copy() + assert deque == sequence + assert temp == sequence + + def test_count(deque): deque += 'abbcccddddeeeee' From 4beffe892a6c4352098a79614de40649d7e9f88e Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 20:40:55 -0700 Subject: [PATCH 320/329] Bump version to 5.6.0 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 134c88a..8647b9a 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.5.2' -__build__ = 0x050502 +__version__ = '5.6.0' +__build__ = 0x050600 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From cffbcec2b198e3a296ec294bd43da37fc559645b Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 22:30:26 -0700 Subject: [PATCH 321/329] Fix docs re: JSONDisk --- docs/tutorial.rst | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 69277d3..2eb454d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -821,30 +821,28 @@ example below uses compressed JSON, available for convenience as .. code-block:: python - import json, zlib - class JSONDisk(diskcache.Disk): def __init__(self, directory, compress_level=1, **kwargs): self.compress_level = compress_level - super(JSONDisk, self).__init__(directory, **kwargs) + super().__init__(directory, **kwargs) def put(self, key): json_bytes = json.dumps(key).encode('utf-8') data = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).put(data) + return super().put(data) def get(self, key, raw): - data = super(JSONDisk, self).get(key, raw) + data = super().get(key, raw) return json.loads(zlib.decompress(data).decode('utf-8')) - def store(self, value, read): + def store(self, value, read, key=UNKNOWN): if not read: json_bytes = json.dumps(value).encode('utf-8') value = zlib.compress(json_bytes, self.compress_level) - return super(JSONDisk, self).store(value, read) + return super().store(value, read, key=key) def fetch(self, mode, filename, value, read): - data = super(JSONDisk, self).fetch(mode, filename, value, read) + data = super().fetch(mode, filename, value, read) if not read: data = json.loads(zlib.decompress(data).decode('utf-8')) return data From f81160f22af9e8af0e07e179808280188146a020 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 22:46:44 -0700 Subject: [PATCH 322/329] Support pathlib.Path as directory argument --- diskcache/core.py | 1 + diskcache/fanout.py | 1 + tests/test_core.py | 8 ++++++++ tests/test_fanout.py | 8 ++++++++ 4 files changed, 18 insertions(+) diff --git a/diskcache/core.py b/diskcache/core.py index 9f3a597..af65454 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -433,6 +433,7 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') + directory = str(directory) directory = op.expanduser(directory) directory = op.expandvars(directory) diff --git a/diskcache/fanout.py b/diskcache/fanout.py index 50005fc..9822ee4 100644 --- a/diskcache/fanout.py +++ b/diskcache/fanout.py @@ -30,6 +30,7 @@ def __init__( """ if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') + directory = str(directory) directory = op.expanduser(directory) directory = op.expandvars(directory) diff --git a/tests/test_core.py b/tests/test_core.py index 356d104..788afef 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,6 +5,7 @@ import io import os import os.path as op +import pathlib import pickle import shutil import sqlite3 @@ -37,6 +38,13 @@ def test_init(cache): cache.close() +def test_init_path(cache): + path = pathlib.Path(cache.directory) + other = dc.Cache(path) + other.close() + assert cache.directory == other.directory + + def test_init_disk(): with dc.Cache(disk_pickle_protocol=1, disk_min_file_size=2**20) as cache: key = (None, 0, 'abc') diff --git a/tests/test_fanout.py b/tests/test_fanout.py index deea03f..af221b6 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -5,6 +5,7 @@ import io import os import os.path as op +import pathlib import pickle import shutil import subprocess as sp @@ -44,6 +45,13 @@ def test_init(cache): cache.check() +def test_init_path(cache): + path = pathlib.Path(cache.directory) + other = dc.FanoutCache(path) + other.close() + assert cache.directory == other.directory + + def test_set_get_delete(cache): for value in range(100): cache.set(value, value) From 4d3068625a3edcd2f5a1f6f104ef621f1f7ea395 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Mon, 17 Apr 2023 22:47:22 -0700 Subject: [PATCH 323/329] Bump version to 5.6.1 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 8647b9a..1931a0d 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.6.0' -__build__ = 0x050600 +__version__ = '5.6.1' +__build__ = 0x050601 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From 17a5f42facc312dae6e98b7b53345e2ed02be21d Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 30 Aug 2023 22:56:38 -0700 Subject: [PATCH 324/329] Bug fix: Fix peek when value is so large that a file is used (#288) Error caused by copy/paste from pull(). --- diskcache/core.py | 3 --- tests/test_deque.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/diskcache/core.py b/diskcache/core.py index af65454..c7c8486 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -1703,9 +1703,6 @@ def peek( except IOError: # Key was deleted before we could retrieve result. continue - finally: - if name is not None: - self._disk.remove(name) break if expire_time and tag: diff --git a/tests/test_deque.py b/tests/test_deque.py index 71c69e2..f997a86 100644 --- a/tests/test_deque.py +++ b/tests/test_deque.py @@ -302,3 +302,13 @@ def test_rotate_indexerror_negative(deque): with mock.patch.object(deque, '_cache', cache): deque.rotate(-1) + + +def test_peek(deque): + value = b'x' * 100_000 + deque.append(value) + assert len(deque) == 1 + assert deque.peek() == value + assert len(deque) == 1 + assert deque.peek() == value + assert len(deque) == 1 From 63a5f6068b77fe9c02c8f310758fa1f05ae1ae04 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 30 Aug 2023 22:58:11 -0700 Subject: [PATCH 325/329] Bump version to 5.6.2 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 1931a0d..719640f 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.6.1' -__build__ = 0x050601 +__version__ = '5.6.2' +__build__ = 0x050602 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From 23d10dce8f4be9c00df4786d508964b3b7d72b27 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 30 Aug 2023 23:09:54 -0700 Subject: [PATCH 326/329] Update release.yml to use pypa/gh-action-pypi-publish --- .github/workflows/release.yml | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efe73c6..33b3a8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,35 +9,22 @@ jobs: upload: runs-on: ubuntu-latest + permissions: + id-token: write steps: - uses: actions/checkout@v3 - - name: Install libmemcached-dev - run: | - sudo apt-get update - sudo apt-get install libmemcached-dev - - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - - name: Install dependencies - run: | - pip install --upgrade pip - pip install -r requirements-dev.txt - - - name: Create source dist - run: python setup.py sdist + - name: Install build + run: pip install build - - name: Create wheel dist - run: python setup.py bdist_wheel + - name: Create build + run: python -m build - - name: Upload with twine - env: - TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} - TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} - run: | - ls -l dist/* - twine upload dist/* + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From 323787f507a6456c56cce213156a78b17073fe00 Mon Sep 17 00:00:00 2001 From: Grant Jenks Date: Wed, 30 Aug 2023 23:10:27 -0700 Subject: [PATCH 327/329] Bump version to 5.6.3 --- diskcache/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diskcache/__init__.py b/diskcache/__init__.py index 719640f..7757d66 100644 --- a/diskcache/__init__.py +++ b/diskcache/__init__.py @@ -61,8 +61,8 @@ pass __title__ = 'diskcache' -__version__ = '5.6.2' -__build__ = 0x050602 +__version__ = '5.6.3' +__build__ = 0x050603 __author__ = 'Grant Jenks' __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2016-2023 Grant Jenks' From 9cd3816333fa34cb30d6cc2a7f227b6b1cdb793c Mon Sep 17 00:00:00 2001 From: ddorian Date: Tue, 27 Feb 2024 00:30:46 +0100 Subject: [PATCH 328/329] Change `Cache_expire_time` to a partial index because we don't need to query rows efficiently `where expire_time IS NULL` (#305) --- diskcache/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index c7c8486..ad9ad4c 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -531,7 +531,7 @@ def __init__(self, directory=None, timeout=60, disk=Disk, **settings): sql( 'CREATE INDEX IF NOT EXISTS Cache_expire_time ON' - ' Cache (expire_time)' + ' Cache (expire_time) WHERE expire_time IS NOT NULL' ) query = EVICTION_POLICY[self.eviction_policy]['init'] From ebfa37cd99d7ef716ec452ad8af4b4276a8e2233 Mon Sep 17 00:00:00 2001 From: ddorian Date: Sun, 3 Mar 2024 02:19:29 +0100 Subject: [PATCH 329/329] Change `Cache_tag_rowid` to a partial index because we don't need to query rows efficiently `where tag IS NULL` (#307) * Change `Cache_tag_rowid` to a partial index because we don't need to query rows efficiently `where tag IS NULL` * Fix formatting --- diskcache/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/diskcache/core.py b/diskcache/core.py index ad9ad4c..7a3d23b 100644 --- a/diskcache/core.py +++ b/diskcache/core.py @@ -2028,7 +2028,10 @@ def create_tag_index(self): """ sql = self._sql - sql('CREATE INDEX IF NOT EXISTS Cache_tag_rowid ON Cache(tag, rowid)') + sql( + 'CREATE INDEX IF NOT EXISTS Cache_tag_rowid ON Cache(tag, rowid) ' + 'WHERE tag IS NOT NULL' + ) self.reset('tag_index', 1) def drop_tag_index(self):