Repository: 3YOURMIND/django-migration-linter
Pull request #26 (2018-12-20 13:49:06)

#23 - Cache
Everything is finished, except the integration with #20.
I will do the integration as soon as #20 is merged.

As you see, I chose to use pickle instead of JSON. (Because https://github.com/ambv/black does the same... ;) )

Also, this PR removes the possibility to call `lint_migration()` with a migration-prefix instead of the full name of the migration. Reconstructing the filename from the prefix would be unnecessarily complex. The filename is needed for the hash. Do you think that's a problem?

For the cache default location appdir.user_cache_dir() is used (Like Black).
The location is something like `/home/felix/.cache/django-migration-linter/0.0.7/test_project_drop_column.pickle`.

For having the version in that path, I created a `__version__` constant in `constants.py`, which is read in setup.py (`get_version()` looks ugly, but apparently [this is the way to go](https://packaging.python.org/guides/single-sourcing-package-version/).)

@David-Wobrock Feel free to wait or review already.


Patch:
diff --git a/README.rst b/README.rst
index d1c5161..9c47e3f 100644
--- a/README.rst
+++ b/README.rst
@@ -44,6 +44,8 @@ Usage
 ``--exclude-apps EXCLUDE_APPS [EXCLUDE_APPS ...]`` Ignore migrations that are in the specified django apps.
 ``--verbose or -v``                                Print more information during execution.
 ``--database DATABASE``                            Specify the database for which to generate the SQL. Defaults to *default*.
+``--cache-path PATH``                              specify a directory that should be used to store cache-files in.
+``--no-cache``                                     Don't use a cache.
 ================================================== ===========================================================================================================================
 
 Examples
@@ -68,6 +70,15 @@ You can also ignore migrations by adding this to your migrations:
         ]
     # ...
 
+Cache
+-----
+By default, the linter uses a cache to prevent linting the same migration again.
+The default location of the cache on Linux is
+``/home/<username>/.cache/django-migration-linter/<version>/<ldjango-project>.pickle``.
+
+Since the linter uses hashes, modifying migration files, will re-run the linter on that migration.
+If you want to run the linter without cache, use the flag ``--no-cache``.
+If you want to invalidate the cache, delete the cache folder.
 
 Tests
 -----
diff --git a/django_migration_linter/cache.py b/django_migration_linter/cache.py
new file mode 100644
index 0000000..0ddd08c
--- /dev/null
+++ b/django_migration_linter/cache.py
@@ -0,0 +1,52 @@
+# Copyright 2018 3YOURMIND GmbH
+
+# 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.
+
+import hashlib
+import os
+import pickle
+
+from django_migration_linter.utils import split_path, compose_migration_path
+
+
+class Cache(dict):
+    def __init__(self, django_folder, cache_path):
+        self.django_folder = django_folder
+        project_name = split_path(django_folder)[-2]
+        self.filename = \
+            os.path.join(cache_path, '{0}.pickle'.format(project_name))
+
+        if not os.path.exists(os.path.dirname(self.filename)):
+            os.makedirs(os.path.dirname(self.filename))
+
+        super(Cache, self).__init__()
+
+    def load(self):
+        try:
+            with open(self.filename, 'rb') as f:
+                tmp_dict = pickle.load(f)
+                self.update(tmp_dict)
+        except IOError:
+            pass
+
+    def save(self):
+        with open(self.filename, 'wb') as f:
+            pickle.dump(self, f, protocol=2)
+
+    def md5(self, app_name, migration):
+        path = compose_migration_path(self.django_folder, app_name, migration)
+        hash_md5 = hashlib.md5()
+        with open(path, "rb") as f:
+            for chunk in iter(lambda: f.read(4096), b""):
+                hash_md5.update(chunk)
+        return hash_md5.hexdigest()
diff --git a/django_migration_linter/constants.py b/django_migration_linter/constants.py
new file mode 100644
index 0000000..1bdfc9b
--- /dev/null
+++ b/django_migration_linter/constants.py
@@ -0,0 +1,23 @@
+# Copyright 2018 3YOURMIND GmbH
+
+# 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.
+
+from appdirs import user_cache_dir
+
+__version__ = '0.0.7'
+
+MIGRATION_FOLDER_NAME = 'migrations'
+
+DEFAULT_CACHE_PATH = user_cache_dir(
+    "django-migration-linter", version=__version__
+)
diff --git a/django_migration_linter/migration_linter.py b/django_migration_linter/migration_linter.py
index e049ed5..e3d1fd3 100644
--- a/django_migration_linter/migration_linter.py
+++ b/django_migration_linter/migration_linter.py
@@ -19,6 +19,10 @@ import re
 from subprocess import Popen, PIPE
 import sys
 
+from django_migration_linter.cache import Cache
+from django_migration_linter.constants import DEFAULT_CACHE_PATH, \
+    MIGRATION_FOLDER_NAME
+from django_migration_linter.utils import split_migration_path
 from . import utils
 from .sql_analyser import analyse_sql_statements
 
@@ -26,7 +30,6 @@ logger = logging.getLogger(__name__)
 
 
 class MigrationLinter(object):
-    MIGRATION_FOLDER_NAME = 'migrations'
 
     def __init__(self, project_path, **kwargs):
         # Verify correctness
@@ -46,6 +49,9 @@ class MigrationLinter(object):
         self.include_apps = kwargs.get('include_apps', None)
         self.exclude_apps = kwargs.get('exclude_apps', None)
         self.database = kwargs.get('database', None) or 'default'
+        self.cache_path = kwargs.get('cache_path', None) or DEFAULT_CACHE_PATH
+        self.no_cache = kwargs.get('no_cache', None) or False
+
         self.python_exe = '{0}/bin/{1}'.format(sys.prefix, 'python') if \
             hasattr(sys, 'real_prefix') else 'python'
 
@@ -55,10 +61,34 @@ class MigrationLinter(object):
         self.nb_erroneous = 0
         self.nb_total = 0
 
+        # Initialise cache. Read from old, write to new to prune old entries.
+        self.old_cache = Cache(self.django_path, self.cache_path)
+        self.new_cache = Cache(self.django_path, self.cache_path)
+        if not self.no_cache:
+            self.old_cache.load()
+
     def lint_migration(self, app_name, migration_name):
         print('({0}, {1})... '.format(app_name, migration_name), end='')
         self.nb_total += 1
 
+        md5hash = self.old_cache.md5(app_name, migration_name)
+        if md5hash in self.old_cache:
+            if self.old_cache[md5hash]['result'] == 'IGNORE':
+                print('IGNORE (cached)')
+                self.nb_ignored += 1
+            elif self.old_cache[md5hash]['result'] == 'OK':
+                print('OK (cached)')
+                self.nb_valid += 1
+            else:
+                print('ERR (cached)')
+                self.nb_erroneous += 1
+
+            if 'errors' in self.old_cache[md5hash]:
+                self.print_errors(self.old_cache[md5hash]['errors'])
+
+            self.new_cache[md5hash] = self.old_cache[md5hash]
+            return
+
         if self.should_ignore_migration(app_name, migration_name):
             print('IGNORE')
             self.nb_ignored += 1
@@ -72,17 +102,23 @@ class MigrationLinter(object):
 
         if analysis_result['ignored']:
             print('IGNORE')
+            self.new_cache[md5hash] = {'result': 'IGNORE'}
             self.nb_ignored += 1
             return
 
         if not errors:
             print('OK')
+            self.new_cache[md5hash] = {'result': 'OK'}
             self.nb_valid += 1
             return
 
-        # Print errors
         print('ERR')
+        self.new_cache[md5hash] = {'result': 'ERR', 'errors': errors}
         self.nb_erroneous += 1
+        self.print_errors(errors)
+
+    @staticmethod
+    def print_errors(errors):
         for err in errors:
             error_str = '\t{0}'.format(err['err_msg'])
             if err['table']:
@@ -108,6 +144,9 @@ class MigrationLinter(object):
         for m in migrations:
             self.lint_migration(*m)
 
+        if not self.no_cache:
+            self.new_cache.save()
+
     def print_summary(self):
         print('*** Summary:')
         print(('Valid migrations: {1}/{0} - '
@@ -173,10 +212,10 @@ class MigrationLinter(object):
                 utils.clean_bytes_to_str, diff_process.stdout.readlines()):
             # Only gather lines that include added migrations
             if re.search(
-                    r'\/{0}\/.*\.py'.format(self.MIGRATION_FOLDER_NAME),
+                    r'\/{0}\/.*\.py'.format(MIGRATION_FOLDER_NAME),
                     line) and \
                         '__init__' not in line:
-                app_name, migration_name = self._split_migration_path(line)
+                app_name, migration_name = split_migration_path(line)
                 migrations.append((app_name, migration_name))
         diff_process.wait()
 
@@ -194,11 +233,11 @@ class MigrationLinter(object):
         migrations = []
         for root, dirs, files in os.walk(self.django_path):
             for file_name in sorted(files):
-                if os.path.basename(root) == self.MIGRATION_FOLDER_NAME and \
+                if os.path.basename(root) == MIGRATION_FOLDER_NAME and \
                         file_name.endswith('.py') and \
                         file_name != '__init__.py':
                     full_migration_path = os.path.join(root, file_name)
-                    app_name, migration_name = self._split_migration_path(
+                    app_name, migration_name = split_migration_path(
                         full_migration_path)
                     migrations.append((app_name, migration_name))
         return migrations
@@ -212,14 +251,6 @@ class MigrationLinter(object):
                 self.ignore_name_contains in migration_name)\
             or (migration_name in self.ignore_name)
 
-    @classmethod
-    def _split_migration_path(cls, migration_path):
-        decomposed_path = utils.split_path(migration_path)
-        for i, p in enumerate(decomposed_path):
-            if p == cls.MIGRATION_FOLDER_NAME:
-                return (decomposed_path[i-1],
-                        os.path.splitext(decomposed_path[i+1])[0])
-
 
 def _main():
     import argparse
@@ -260,6 +291,18 @@ def _main():
         help=('specify the database for which to generate the SQL. '
               'Defaults to default'))
 
+    cache_group = parser.add_mutually_exclusive_group(
+        required=False)
+    cache_group.add_argument(
+        '--cache-path',
+        type=str,
+        help='specify a directory that should be used to'
+             'store cache-files in.')
+    cache_group.add_argument(
+        '--no-cache',
+        action='store_true',
+        help='don\'t use a cache')
+
     incl_excl_group = parser.add_mutually_exclusive_group(
         required=False)
     incl_excl_group.add_argument(
@@ -287,7 +330,10 @@ def _main():
         ignore_name=args.ignore_name,
         include_apps=args.include_apps,
         exclude_apps=args.exclude_apps,
-        database=args.database)
+        database=args.database,
+        cache_path=args.cache_path,
+        no_cache=args.no_cache
+    )
     linter.lint_all_migrations(
         git_commit_id=args.commit_id)
     linter.print_summary()
diff --git a/django_migration_linter/utils.py b/django_migration_linter/utils.py
index b2b8f23..355df5f 100644
--- a/django_migration_linter/utils.py
+++ b/django_migration_linter/utils.py
@@ -13,9 +13,12 @@
 # limitations under the License.
 
 from __future__ import print_function
+
 import os
 import sys
 
+from django_migration_linter.constants import MIGRATION_FOLDER_NAME
+
 
 def is_django_project(path):
     django_manage_file = os.path.join(path, 'manage.py')
@@ -52,5 +55,22 @@ def split_path(path):
     return (split_path(a) if (len(a) > 0 and a != '/') else []) + [b]
 
 
+def split_migration_path(migration_path):
+    decomposed_path = split_path(migration_path)
+    for i, p in enumerate(decomposed_path):
+        if p == MIGRATION_FOLDER_NAME:
+            return (decomposed_path[i-1],
+                    os.path.splitext(decomposed_path[i+1])[0])
+
+
+def compose_migration_path(django_folder, app_name, migration):
+    return os.path.join(
+        django_folder,
+        app_name,
+        MIGRATION_FOLDER_NAME,
+        '{0}.py'.format(migration)
+    )
+
+
 def clean_bytes_to_str(byte_input):
     return byte_input.decode('utf-8').strip()
diff --git a/setup.py b/setup.py
index 79ca34d..dd6bee3 100644
--- a/setup.py
+++ b/setup.py
@@ -11,18 +11,30 @@
 # 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.
-
+import ast
+import re
 from os import path
 from setuptools import setup, find_packages
 
 
 PROJECT_DIR = path.abspath(path.dirname(__file__))
 
+
+def get_version():
+    constants = path.join(PROJECT_DIR, 'django_migration_linter', 'constants.py')
+    _version_re = re.compile(r"__version__\s+=\s+(?P<version>.*)")
+    with open(constants, "r") as f:
+        match = _version_re.search(f.read())
+        version = match.group("version") if match is not None else '"unknown"'
+    return str(ast.literal_eval(version))
+
+
 with open(path.join(PROJECT_DIR, 'README.rst')) as f:
     long_description = f.read()
 
 install_requirements = [
     'django>=1.11',
+    'appdirs==1.4.3'
 ]
 
 test_requirements = [
@@ -31,9 +43,10 @@ test_requirements = [
     'django-fake-database-backends',
 ]
 
+# noinspection PyUnresolvedReferences
 setup(
     name='django-migration-linter',
-    version='0.0.7',
+    version=get_version(),
 
     description='Detect backward incompatible migrations for your django project',
     long_description=long_description,
diff --git a/tox.ini b/tox.ini
index 7ffc655..a420feb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,6 +11,8 @@ pip_pre = true
 deps =
     django111: django>=1.11,<2.0
     django20: django>=2.0
+    appdirs==1.4.3
+    py27: mock==2.0.0
 
 [testenv:lint]
 basepython = python3.6


Test patch:
diff --git a/tests/functional/test_cache.py b/tests/functional/test_cache.py
new file mode 100644
index 0000000..ae289b2
--- /dev/null
+++ b/tests/functional/test_cache.py
@@ -0,0 +1,165 @@
+# Copyright 2018 3YOURMIND GmbH
+
+# 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.
+import os
+import pprint
+import shutil
+import tempfile
+import unittest
+import sys
+
+from tests import fixtures
+
+from django_migration_linter import MigrationLinter, Cache, DEFAULT_CACHE_PATH
+
+if sys.version_info >= (3, 3):
+    import unittest.mock as mock
+else:
+    import mock
+
+
+class CacheTest(unittest.TestCase):
+    MIGRATION_FILE = os.path.join(fixtures.ALTER_COLUMN_PROJECT, 'test_app', 'migrations', '0001_initial.py')
+    fd, temp_path = tempfile.mkstemp()
+
+    def setUp(self):
+        shutil.copy2(self.MIGRATION_FILE, self.temp_path)
+
+    def tearDown(self):
+        shutil.copy2(self.temp_path, self.MIGRATION_FILE)
+        os.remove(self.temp_path)
+
+    def test_cache_normal(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_project_add_not_null_column.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+        linter = MigrationLinter(fixtures.ADD_NOT_NULL_COLUMN_PROJECT)
+
+        with mock.patch.object(MigrationLinter, 'get_sql', wraps=linter.get_sql)as sql_mock:
+            linter.lint_all_migrations()
+            self.assertEqual(sql_mock.call_count, 2)
+
+        cache = Cache(
+            fixtures.ADD_NOT_NULL_COLUMN_PROJECT,
+            DEFAULT_CACHE_PATH
+        )
+        cache.load()
+
+        self.assertEqual(cache['3ef74e7f3e53e273e2fc95379248d58d']['result'], 'OK')
+        self.assertEqual(cache['e1e312b6d08ecbe017c25c58fc2be257']['result'], 'ERR')
+        self.assertListEqual(
+            cache['e1e312b6d08ecbe017c25c58fc2be257']['errors'],
+            [{'err_msg': 'RENAMING tables', 'code': 'RENAME_TABLE', 'table': None, 'column': None}]
+        )
+
+        # Start the Linter again -> should use cache now.
+        linter = MigrationLinter(fixtures.ADD_NOT_NULL_COLUMN_PROJECT)
+
+        with mock.patch.object(MigrationLinter, 'get_sql', wraps=linter.get_sql) as sql_mock:
+            linter.lint_all_migrations()
+            self.assertEqual(sql_mock.call_count, 0)
+
+        self.assertTrue(linter.has_errors)
+
+    def test_cache_ignored(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_project_ignore_migration.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+        linter = MigrationLinter(fixtures.IGNORE_MIGRATION_PROJECT)
+
+        with mock.patch.object(MigrationLinter, 'get_sql', wraps=linter.get_sql) as sql_mock:
+            linter.lint_all_migrations()
+            self.assertEqual(sql_mock.call_count, 2)
+
+        cache = Cache(
+            fixtures.IGNORE_MIGRATION_PROJECT,
+            DEFAULT_CACHE_PATH
+        )
+        cache.load()
+
+        self.assertEqual(cache['63230606af0eccaef7f1f78c537c624c']['result'], 'OK')
+        self.assertEqual(cache['5c5ca1780a9f28439c1defc1f32af894']['result'], 'IGNORE')
+
+        # Start the Linter again -> should use cache now.
+        linter = MigrationLinter(fixtures.IGNORE_MIGRATION_PROJECT)
+
+        with mock.patch.object(MigrationLinter, 'get_sql', wraps=linter.get_sql) as sql_mock:
+            linter.lint_all_migrations()
+            self.assertEqual(sql_mock.call_count, 0)
+
+    def test_cache_ignored_command_line(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_project_ignore_migration.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+        linter = MigrationLinter(fixtures.IGNORE_MIGRATION_PROJECT,
+                                 ignore_name_contains='0001')
+
+        with mock.patch.object(MigrationLinter, 'get_sql', wraps=linter.get_sql) as sql_mock:
+            linter.lint_all_migrations()
+            self.assertEqual(sql_mock.call_count, 1)
+
+        cache = Cache(
+            fixtures.IGNORE_MIGRATION_PROJECT,
+            DEFAULT_CACHE_PATH
+        )
+        cache.load()
+
+        self.assertNotIn('63230606af0eccaef7f1f78c537c624c', cache)
+        self.assertEqual(cache['5c5ca1780a9f28439c1defc1f32af894']['result'], 'IGNORE')
+
+    def test_cache_modified(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_project_alter_column.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+        linter = MigrationLinter(fixtures.ALTER_COLUMN_PROJECT)
+        linter.lint_all_migrations()
+        cache = Cache(
+            fixtures.ALTER_COLUMN_PROJECT,
+            DEFAULT_CACHE_PATH
+        )
+        cache.load()
+
+        self.assertEqual(cache['8589aa107b6da296c4b49cd2681d2230']['result'], 'OK',
+                         'If this fails, tearDown might have failed to remove '
+                         'the modification from tests/test_project_fixtures/'
+                         'test_project_alter_column/test_app/migrations/0001_initial.py'
+                         )
+        self.assertEqual(cache['8f54c4a434cfaa9838e8ca12eb988255']['result'], 'ERR')
+        self.assertListEqual(
+            cache['8f54c4a434cfaa9838e8ca12eb988255']['errors'],
+            [{u'err_msg': u'ALTERING columns (Could be backward compatible. '
+                          u'You may ignore this migration.)',
+              u'code': u'ALTER_COLUMN',
+              u'table': u'test_app_a',
+              u'column': None}
+             ]
+        )
+
+        # Modify migration
+        with open(self.MIGRATION_FILE, "a") as f:
+            f.write("# modification at the end of the file\n")
+
+        # Start the Linter again -> Cache should look different now
+        linter = MigrationLinter(fixtures.ALTER_COLUMN_PROJECT)
+        linter.lint_all_migrations()
+        cache = Cache(
+            fixtures.ALTER_COLUMN_PROJECT,
+            DEFAULT_CACHE_PATH
+        )
+        cache.load()
+
+        pp = pprint.PrettyPrinter(indent=4)
+        pp.pprint(cache)
+
+        self.assertNotIn('8589aa107b6da296c4b49cd2681d2230', cache)
+        self.assertEqual(cache['44f9d6d019d6f158946c2819b2bd8bf9']['result'], 'OK')
diff --git a/tests/functional/test_cmd_line_call.py b/tests/functional/test_cmd_line_call.py
index abc9479..b8ddabe 100644
--- a/tests/functional/test_cmd_line_call.py
+++ b/tests/functional/test_cmd_line_call.py
@@ -12,9 +12,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
+import shutil
 import unittest
 from subprocess import Popen, PIPE
-from django_migration_linter import utils
+from django_migration_linter import utils, DEFAULT_CACHE_PATH
 from tests import fixtures
 import sys
 
@@ -27,7 +29,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         cls.linter_exec = '{0}/bin/{1}'.format(sys.prefix,  linter_name) if hasattr(sys, 'real_prefix') else linter_name
 
     def test_call_linter_cmd_line_working(self):
-        cmd = '{0} {1}'.format(self.linter_exec, fixtures.CORRECT_PROJECT)
+        cmd = '{0} --no-cache {1}'.format(self.linter_exec, fixtures.CORRECT_PROJECT)
 
         process = Popen(
             cmd, shell=True, stdout=PIPE, stderr=PIPE)
@@ -45,7 +47,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         )
 
     def test_call_linter_cmd_line_errors(self):
-        cmd = '{0} {1}'.format(
+        cmd = '{0} --no-cache {1}'.format(
             self.linter_exec,
             fixtures.ADD_NOT_NULL_COLUMN_PROJECT)
 
@@ -60,7 +62,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         self.assertTrue('RENAMING tables' in lines[2])
 
     def test_call_linter_cmd_line_exclude_apps(self):
-        cmd = '{0} {1} --exclude-apps test_app2'.format(
+        cmd = '{0} --no-cache {1} --exclude-apps test_app2'.format(
             self.linter_exec,
             fixtures.CORRECT_PROJECT)
 
@@ -80,7 +82,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         )
 
     def test_call_linter_cmd_line_include_apps(self):
-        cmd = '{0} {1} --include-apps test_app2'.format(
+        cmd = '{0} --no-cache {1} --include-apps test_app2'.format(
             self.linter_exec,
             fixtures.CORRECT_PROJECT)
 
@@ -100,7 +102,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         )
 
     def test_call_linter_cmd_line_ignore_name(self):
-        cmd = '{0} {1} --ignore-name 0001_initial'.format(
+        cmd = '{0} --no-cache {1} --ignore-name 0001_initial'.format(
             self.linter_exec,
             fixtures.CORRECT_PROJECT)
 
@@ -120,7 +122,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         )
 
     def test_call_linter_cmd_line_ignore_name_contains(self):
-        cmd = '{0} {1} --ignore-name-contains 0001'.format(
+        cmd = '{0} --no-cache {1} --ignore-name-contains 0001'.format(
             self.linter_exec,
             fixtures.CORRECT_PROJECT)
 
@@ -139,8 +141,53 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
              ])
         )
 
+    def test_call_linter_cmd_line_cache(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_correct_project.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+        cmd = '{0} {1}'.format(
+            self.linter_exec,
+            fixtures.CORRECT_PROJECT
+        )
+
+        process = Popen(
+            cmd, shell=True, stdout=PIPE, stderr=PIPE)
+        process.wait()
+        self.assertTrue(os.path.exists(DEFAULT_CACHE_PATH))
+
+    def test_call_linter_cmd_line_cache_path(self):
+        if os.path.exists('/tmp/migration-linter-cache-tests/'):
+            shutil.rmtree('/tmp/migration-linter-cache-tests/')
+        cmd = '{0} {1} --cache-path={2}'.format(
+            self.linter_exec,
+            fixtures.CORRECT_PROJECT,
+            '/tmp/migration-linter-cache-tests/'
+        )
+
+        process = Popen(
+            cmd, shell=True, stdout=PIPE, stderr=PIPE)
+        process.wait()
+        self.assertEqual(process.returncode, 0)
+        self.assertTrue(os.path.exists('/tmp/migration-linter-cache-tests'))
+
+    def test_call_linter_cmd_line_no_cache(self):
+        cache_file = os.path.join(DEFAULT_CACHE_PATH, 'test_correct_project.pickle')
+        if os.path.exists(cache_file):
+            os.remove(cache_file)
+
+        cmd = '{0} --no-cache {1}'.format(
+            self.linter_exec,
+            fixtures.CORRECT_PROJECT
+        )
+
+        process = Popen(
+            cmd, shell=True, stdout=PIPE, stderr=PIPE)
+        process.wait()
+        self.assertEqual(process.returncode, 0)
+        self.assertFalse(os.path.exists(cache_file))
+
     def test_call_linter_cmd_line_git_id(self):
-        cmd = '{0} {1} d7125d5f4f0cc9623f670a66c54f131acc50032d'.format(
+        cmd = '{0} --no-cache {1} d7125d5f4f0cc9623f670a66c54f131acc50032d'.format(
             self.linter_exec,
             fixtures.MULTI_COMMIT_PROJECT)
         fixtures.prepare_git_project(fixtures.MULTI_COMMIT_PROJECT)
@@ -155,7 +202,7 @@ class CallLinterFromCommandLineTest(unittest.TestCase):
         self.assertTrue(lines[1].startswith('*** Summary'))
 
     def test_call_linter_with_deleted_migrations(self):
-        cmd = '{0} {1} 154ecf6119325cc3b1f3f5a4e709bfbd61a4a4ba'.format(
+        cmd = '{0} --no-cache {1} 154ecf6119325cc3b1f3f5a4e709bfbd61a4a4ba'.format(
             self.linter_exec,
             fixtures.DELETED_MIGRATION_PROJECT)
         fixtures.prepare_git_project(fixtures.DELETED_MIGRATION_PROJECT)
diff --git a/tests/functional/test_multiple_calls.py b/tests/functional/test_multiple_calls.py
index bea9262..0920c1b 100644
--- a/tests/functional/test_multiple_calls.py
+++ b/tests/functional/test_multiple_calls.py
@@ -24,10 +24,10 @@ class TestMultipleLinters(unittest.TestCase):
         l2 = MigrationLinter(fixtures.RENAME_COLUMN_PROJECT)
         l3 = MigrationLinter(fixtures.CORRECT_PROJECT)
 
-        l1.lint_migration('test_app', '0002')
-        l2.lint_migration('test_app', '0002')
-        l3.lint_migration('test_app1', '0001')
-        l3.lint_migration('test_app1', '0002')
+        l1.lint_migration('test_app', '0002_add_new_not_null_field')
+        l2.lint_migration('test_app', '0002_rename_column')
+        l3.lint_migration('test_app1', '0001_initial')
+        l3.lint_migration('test_app1', '0002_a_new_null_field')
 
         self.assertTrue(l1.has_errors)
         self.assertTrue(l2.has_errors)
diff --git a/tests/unit/test_linter.py b/tests/unit/test_linter.py
index 57e449e..1647b10 100644
--- a/tests/unit/test_linter.py
+++ b/tests/unit/test_linter.py
@@ -32,13 +32,13 @@ class TestLinterFunctions(unittest.TestCase):
         linter = MigrationLinter(project_path)
         self.assertFalse(linter.has_errors)
 
-        linter.lint_migration('test_app', '0001')
+        linter.lint_migration('test_app', '0001_create_table')
         self.assertFalse(linter.has_errors)
 
-        linter.lint_migration('test_app', '0002')
+        linter.lint_migration('test_app', '0002_add_new_not_null_field')
         self.assertTrue(linter.has_errors)
 
-        linter.lint_migration('test_app', '0001')
+        linter.lint_migration('test_app', '0001_create_table')
         self.assertTrue(linter.has_errors)
 
     def test_linter_creation(self):
@@ -49,24 +49,6 @@ class TestLinterFunctions(unittest.TestCase):
         with self.assertRaises(ValueError):
             MigrationLinter(fixtures.NOT_DJANGO_GIT_PROJECT)
 
-    def test_split_migration_long_path(self):
-        input_path = 'apps/the_app/migrations/0001_stuff.py'
-        app, mig = MigrationLinter._split_migration_path(input_path)
-        self.assertEqual(app, 'the_app')
-        self.assertEqual(mig, '0001_stuff')
-
-    def test_split_migration_path(self):
-        input_path = 'the_app/migrations/0001_stuff.py'
-        app, mig = MigrationLinter._split_migration_path(input_path)
-        self.assertEqual(app, 'the_app')
-        self.assertEqual(mig, '0001_stuff')
-
-    def test_split_migration_full_path(self):
-        input_path = '/home/user/djangostuff/apps/the_app/migrations/0001_stuff.py'
-        app, mig = MigrationLinter._split_migration_path(input_path)
-        self.assertEqual(app, 'the_app')
-        self.assertEqual(mig, '0001_stuff')
-
     def test_ignore_migration_include_apps(self):
         linter = MigrationLinter(
             fixtures.ADD_NOT_NULL_COLUMN_PROJECT,
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 88d20af..7ac89d5 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -16,7 +16,8 @@ import os
 import unittest
 
 from tests import fixtures
-from django_migration_linter.utils import is_django_project, is_git_project, is_directory, find_project_settings_module, split_path
+from django_migration_linter.utils import is_django_project, is_git_project, is_directory, find_project_settings_module, \
+    split_path, split_migration_path
 
 
 class UtilityFunctionTest(unittest.TestCase):
@@ -70,3 +71,21 @@ class UtilityFunctionTest(unittest.TestCase):
         self.assertEqual(splitted[0], 'foo')
         self.assertEqual(splitted[1], 'bar')
         self.assertEqual(splitted[2], 'fuz.py')
+
+    def test_split_migration_long_path(self):
+        input_path = 'apps/the_app/migrations/0001_stuff.py'
+        app, mig = split_migration_path(input_path)
+        self.assertEqual(app, 'the_app')
+        self.assertEqual(mig, '0001_stuff')
+
+    def test_split_migration_path(self):
+        input_path = 'the_app/migrations/0001_stuff.py'
+        app, mig = split_migration_path(input_path)
+        self.assertEqual(app, 'the_app')
+        self.assertEqual(mig, '0001_stuff')
+
+    def test_split_migration_full_path(self):
+        input_path = '/home/user/djangostuff/apps/the_app/migrations/0001_stuff.py'
+        app, mig = split_migration_path(input_path)
+        self.assertEqual(app, 'the_app')
+        self.assertEqual(mig, '0001_stuff')