diff --git a/jedi/cache.py b/jedi/cache.py index e04b40e3..20805845 100644 --- a/jedi/cache.py +++ b/jedi/cache.py @@ -25,6 +25,7 @@ try: import cPickle as pickle except: import pickle +import shutil from jedi._compatibility import json from jedi import settings @@ -219,13 +220,36 @@ def save_module(path, name, parser, pickling=True): class _ModulePickling(object): + + version = 1 + """ + Version number (integer) for file system cache. + + Increment this number when there are any incompatible changes in + parser representation classes. For example, the following changes + are regarded as incompatible. + + - Class name is changed. + - Class is moved to another module. + - Defined slot of the class is changed. + """ + def __init__(self): self.__index = None - self.py_version = '%s.%s' % sys.version_info[:2] + self.py_tag = 'cpython-%s%s' % sys.version_info[:2] + """ + Short name for distinguish Python implementations and versions. + + It's like `sys.implementation.cache_tag` but for Python < 3.3 + we generate something similar. See: + http://docs.python.org/3/library/sys.html#sys.implementation + + .. todo:: Detect interpreter (e.g., PyPy). + """ def load_module(self, path, original_changed_time): try: - pickle_changed_time = self._index[self.py_version][path] + pickle_changed_time = self._index[path] except KeyError: return None if original_changed_time is not None \ @@ -243,10 +267,10 @@ class _ModulePickling(object): def save_module(self, path, parser_cache_item): self.__index = None try: - files = self._index[self.py_version] + files = self._index except KeyError: files = {} - self._index[self.py_version] = files + self._index = files with open(self._get_hashed_path(path), 'wb') as f: pickle.dump(parser_cache_item, f, pickle.HIGHEST_PROTOCOL) @@ -259,9 +283,16 @@ class _ModulePickling(object): if self.__index is None: try: with open(self._get_path('index.json')) as f: - self.__index = json.load(f) + data = json.load(f) except IOError: self.__index = {} + else: + # 0 means version is not defined (= always delete cache): + if data.get('version', 0) != self.version: + self.delete_cache() + self.__index = {} + else: + self.__index = data['index'] return self.__index def _remove_old_modules(self): @@ -272,18 +303,25 @@ class _ModulePickling(object): self._index # reload index def _flush_index(self): + data = {'version': self.version, 'index': self._index} with open(self._get_path('index.json'), 'w') as f: - json.dump(self._index, f) + json.dump(data, f) self.__index = None + def delete_cache(self): + shutil.rmtree(self._cache_directory()) + def _get_hashed_path(self, path): - return self._get_path('%s_%s.pkl' % (self.py_version, hash(path))) + return self._get_path('%s.pkl' % hash(path)) def _get_path(self, file): - dir = settings.cache_directory + dir = self._cache_directory() if not os.path.exists(dir): os.makedirs(dir) - return dir + os.path.sep + file + return os.path.join(dir, file) + + def _cache_directory(self): + return os.path.join(settings.cache_directory, self.py_tag) # is a singleton diff --git a/test/conftest.py b/test/conftest.py index c5a00879..12a01b13 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -65,6 +65,18 @@ def pytest_generate_tests(metafunc): refactor.collect_dir_tests(base_dir, test_files)) +@pytest.fixture() +def isolated_jedi_cache(monkeypatch, tmpdir): + """ + Set `jedi.settings.cache_directory` to a temporary directory during test. + + Same as `clean_jedi_cache`, but create the temporary directory for + each test case (scope='function'). + """ + settings = base.jedi.settings + monkeypatch.setattr(settings, 'cache_directory', str(tmpdir)) + + @pytest.fixture(scope='session') def clean_jedi_cache(request): """ diff --git a/test/test_cache.py b/test/test_cache.py index 9f845fc2..c27dc705 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -1,3 +1,5 @@ +import pytest + from jedi import settings from jedi.cache import ParserCacheItem, _ModulePickling @@ -21,10 +23,32 @@ def test_modulepickling_change_cache_dir(monkeypatch, tmpdir): monkeypatch.setattr(settings, 'cache_directory', dir_1) ModulePickling.save_module(path_1, item_1) - cached = ModulePickling.load_module(path_1, item_1.change_time - 1) + cached = load_stored_item(ModulePickling, path_1, item_1) assert cached == item_1.parser monkeypatch.setattr(settings, 'cache_directory', dir_2) ModulePickling.save_module(path_2, item_2) - cached = ModulePickling.load_module(path_1, item_1.change_time - 1) + cached = load_stored_item(ModulePickling, path_1, item_1) assert cached is None + + +def load_stored_item(cache, path, item): + """Load `item` stored at `path` in `cache`.""" + return cache.load_module(path, item.change_time - 1) + + +@pytest.mark.usefixtures("isolated_jedi_cache") +def test_modulepickling_delete_incompatible_cache(): + item = ParserCacheItem('fake parser') + path = 'fake path' + + cache1 = _ModulePickling() + cache1.version = 1 + cache1.save_module(path, item) + cached1 = load_stored_item(cache1, path, item) + assert cached1 == item.parser + + cache2 = _ModulePickling() + cache2.version = 2 + cached2 = load_stored_item(cache2, path, item) + assert cached2 is None