Repository: mkdocs/mkdocs

Issue:
Warn users if they are using an old/different version
pip has a nice feature where they warn users if they are using an old version and prompt them to update. I think it would be neat to add something like this. The code they use for it is here: https://github.com/pypa/pip/blob/7.0.3/pip/utils/outdated.py#L95


Discussion:
waylan: Apparently [unittest.TestCase.assertLogs][1] was added in Python 3.4. A PY2.7 backport can be found [here]. The [Unittest2][3] lib also contains a backport, however, I don;t see the need for that entire lib for one feature.

There is also the [testfixtures][4] lib, which includes some [logging capture tools][5], along with a bunch of other stuff that could simplify a bunch of our tests.

[1]: https://docs.python.org/3.4/library/unittest.html#unittest.TestCase.assertLogs
[2]: https://stackoverflow.com/a/29938398/866026
[3]: https://pypi.python.org/pypi/unittest2
[4]: http://testfixtures.readthedocs.io/en/latest/index.html
[5]: http://testfixtures.readthedocs.io/en/latest/logging.html

Patch:
diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md
index 6f0f3518..7afca02a 100644
--- a/docs/about/release-notes.md
+++ b/docs/about/release-notes.md
@@ -66,6 +66,7 @@ authors should review how [search and themes] interact.
 
 ### Other Changes and Additions to Development Version
 
+* Add MkDocs version check to gh-deploy script (#640).
 * Improve Markdown extension error messages. (#782).
 * Drop official support for Python 3.3 and set `tornado>=5.0` (#1427).
 * Add support for GitLab edit links (#1435).
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
index 8c231180..a3f7cdae 100644
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -89,6 +89,7 @@ remote_branch_help = ("The remote branch to commit to for Github Pages. This "
 remote_name_help = ("The remote name to commit to for Github Pages. This "
                     "overrides the value specified in config")
 force_help = "Force the push to the repository."
+ignore_version_help = "Ignore check that build is not being deployed with an older version of MkDocs."
 
 pgk_dir = os.path.dirname(os.path.abspath(__file__))
 
@@ -172,8 +173,9 @@ def build_command(clean, config_file, strict, theme, theme_dir, site_dir):
 @click.option('-b', '--remote-branch', help=remote_branch_help)
 @click.option('-r', '--remote-name', help=remote_name_help)
 @click.option('--force', is_flag=True, help=force_help)
+@click.option('--ignore-version', is_flag=True, help=ignore_version_help)
 @common_options
-def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, force):
+def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, force, ignore_version):
     """Deploy your documentation to GitHub Pages"""
     try:
         cfg = config.load_config(
@@ -182,7 +184,7 @@ def gh_deploy_command(config_file, clean, message, remote_branch, remote_name, f
             remote_name=remote_name
         )
         build.build(cfg, dirty=not clean)
-        gh_deploy.gh_deploy(cfg, message=message, force=force)
+        gh_deploy.gh_deploy(cfg, message=message, force=force, ignore_version=ignore_version)
     except exceptions.ConfigurationError as e:  # pragma: no cover
         # Avoid ugly, unhelpful traceback
         raise SystemExit('\n' + str(e))
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index ccadab6d..b3d0548f 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -2,6 +2,8 @@ from __future__ import unicode_literals
 import logging
 import subprocess
 import os
+import re
+from pkg_resources import parse_version
 
 import mkdocs
 from mkdocs.utils import ghp_import
@@ -49,20 +51,49 @@ def _get_remote_url(remote_name):
     return host, path
 
 
-def gh_deploy(config, message=None, force=False):
+def _check_version(branch):
+
+    proc = subprocess.Popen(['git', 'show', '-s', '--format=%s', 'refs/heads/{}'.format(branch)],
+                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+    stdout, _ = proc.communicate()
+    msg = stdout.decode('utf-8').strip()
+    m = re.search(r'\d+(\.\d+)+', msg, re.X | re.I)
+    previousv = parse_version(m.group()) if m else None
+    currentv = parse_version(mkdocs.__version__)
+    if not previousv:
+        log.warn('Version check skipped: No version specificed in previous deployment.')
+    elif currentv > previousv:
+        log.info(
+            'Previous deployment was done with MkDocs version {}; '
+            'you are deploying with a newer version ({})'.format(previousv, currentv)
+        )
+    elif currentv < previousv:
+        log.error(
+            'Deployment terminated: Previous deployment was made with MkDocs version {}; '
+            'you are attempting to deploy with an older version ({}). Use --ignore-version '
+            'to deploy anyway.'.format(previousv, currentv)
+        )
+        raise SystemExit(1)
+
+
+def gh_deploy(config, message=None, force=False, ignore_version=False):
 
     if not _is_cwd_git_repo():
         log.error('Cannot deploy - this directory does not appear to be a git '
                   'repository')
 
+    remote_branch = config['remote_branch']
+    remote_name = config['remote_name']
+
+    if not ignore_version:
+        _check_version(remote_branch)
+
     if message is None:
         message = default_message
     sha = _get_current_sha(os.path.dirname(config.config_file_path))
     message = message.format(version=mkdocs.__version__, sha=sha)
 
-    remote_branch = config['remote_branch']
-    remote_name = config['remote_name']
-
     log.info("Copying '%s' to '%s' branch and pushing to GitHub.",
              config['site_dir'], config['remote_branch'])
 


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
index 20ba63ec..a17fc2e0 100644
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -2,9 +2,14 @@ from __future__ import unicode_literals
 import textwrap
 import markdown
 import os
+import logging
+import collections
+import unittest
+
 
 from mkdocs import toc
 from mkdocs import config
+from mkdocs import utils
 
 
 def dedent(text):
@@ -37,3 +42,97 @@ def load_config(**cfg):
     errors_warnings = conf.validate()
     assert(errors_warnings == ([], [])), errors_warnings
     return conf
+
+
+# Backport unittest.TestCase.assertLogs for Python 2.7
+# see https://github.com/python/cpython/blob/3.6/Lib/unittest/case.py
+
+if not utils.PY3:
+    _LoggingWatcher = collections.namedtuple("_LoggingWatcher",
+                                             ["records", "output"])
+
+    class _CapturingHandler(logging.Handler):
+        """
+        A logging handler capturing all (raw and formatted) logging output.
+        """
+
+        def __init__(self):
+            logging.Handler.__init__(self)
+            self.watcher = _LoggingWatcher([], [])
+
+        def flush(self):
+            pass
+
+        def emit(self, record):
+            self.watcher.records.append(record)
+            msg = self.format(record)
+            self.watcher.output.append(msg)
+
+    class _AssertLogsContext(object):
+        """A context manager used to implement TestCase.assertLogs()."""
+
+        LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s"
+
+        def __init__(self, test_case, logger_name, level):
+            self.test_case = test_case
+            self.logger_name = logger_name
+            if level:
+                self.level = logging._levelNames.get(level, level)
+            else:
+                self.level = logging.INFO
+            self.msg = None
+
+        def __enter__(self):
+            if isinstance(self.logger_name, logging.Logger):
+                logger = self.logger = self.logger_name
+            else:
+                logger = self.logger = logging.getLogger(self.logger_name)
+            formatter = logging.Formatter(self.LOGGING_FORMAT)
+            handler = _CapturingHandler()
+            handler.setFormatter(formatter)
+            self.watcher = handler.watcher
+            self.old_handlers = logger.handlers[:]
+            self.old_level = logger.level
+            self.old_propagate = logger.propagate
+            logger.handlers = [handler]
+            logger.setLevel(self.level)
+            logger.propagate = False
+            return handler.watcher
+
+        def __exit__(self, exc_type, exc_value, tb):
+            self.logger.handlers = self.old_handlers
+            self.logger.propagate = self.old_propagate
+            self.logger.setLevel(self.old_level)
+            if exc_type is not None:
+                # let unexpected exceptions pass through
+                return False
+            if len(self.watcher.records) == 0:
+                self._raiseFailure(
+                    "no logs of level {} or higher triggered on {}"
+                    .format(logging.getLevelName(self.level), self.logger.name))
+
+        def _raiseFailure(self, standardMsg):
+            msg = self.test_case._formatMessage(self.msg, standardMsg)
+            raise self.test_case.failureException(msg)
+
+    class LogTestCase(unittest.TestCase):
+        def assertLogs(self, logger=None, level=None):
+            """Fail unless a log message of level *level* or higher is emitted
+            on *logger_name* or its children.  If omitted, *level* defaults to
+            INFO and *logger* defaults to the root logger.
+            This method must be used as a context manager, and will yield
+            a recording object with two attributes: `output` and `records`.
+            At the end of the context manager, the `output` attribute will
+            be a list of the matching formatted log messages and the
+            `records` attribute will be a list of the corresponding LogRecord
+            objects.
+            Example::
+                with self.assertLogs('foo', level='INFO') as cm:
+                    logging.getLogger('foo').info('first message')
+                    logging.getLogger('foo.bar').error('second message')
+                self.assertEqual(cm.output, ['INFO:foo:first message',
+                                             'ERROR:foo.bar:second message'])
+            """
+            return _AssertLogsContext(self, logger, level)
+else:
+    LogTestCase = unittest.TestCase
diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py
index 57f12845..939096f7 100644
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -346,6 +346,8 @@ class CLITests(unittest.TestCase):
         self.assertEqual(g_kwargs['message'], None)
         self.assertTrue('force' in g_kwargs)
         self.assertEqual(g_kwargs['force'], False)
+        self.assertTrue('ignore_version' in g_kwargs)
+        self.assertEqual(g_kwargs['ignore_version'], False)
         self.assertEqual(mock_build.call_count, 1)
         b_args, b_kwargs = mock_build.call_args
         self.assertTrue('dirty' in b_kwargs)
@@ -471,3 +473,19 @@ class CLITests(unittest.TestCase):
         self.assertEqual(g_kwargs['force'], True)
         self.assertEqual(mock_build.call_count, 1)
         self.assertEqual(mock_load_config.call_count, 1)
+
+    @mock.patch('mkdocs.config.load_config', autospec=True)
+    @mock.patch('mkdocs.commands.build.build', autospec=True)
+    @mock.patch('mkdocs.commands.gh_deploy.gh_deploy', autospec=True)
+    def test_gh_deploy_ognore_version(self, mock_gh_deploy, mock_build, mock_load_config):
+
+        result = self.runner.invoke(
+            cli.cli, ['gh-deploy', '--ignore-version'], catch_exceptions=False)
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual(mock_gh_deploy.call_count, 1)
+        g_args, g_kwargs = mock_gh_deploy.call_args
+        self.assertTrue('ignore_version' in g_kwargs)
+        self.assertEqual(g_kwargs['ignore_version'], True)
+        self.assertEqual(mock_build.call_count, 1)
+        self.assertEqual(mock_load_config.call_count, 1)
diff --git a/mkdocs/tests/gh_deploy_tests.py b/mkdocs/tests/gh_deploy_tests.py
index 7b8793bc..495a6e90 100644
--- a/mkdocs/tests/gh_deploy_tests.py
+++ b/mkdocs/tests/gh_deploy_tests.py
@@ -3,8 +3,9 @@ from __future__ import unicode_literals
 import unittest
 import mock
 
-from mkdocs.tests.base import load_config
+from mkdocs.tests.base import load_config, LogTestCase
 from mkdocs.commands import gh_deploy
+from mkdocs import __version__
 
 
 class TestGitHubDeploy(unittest.TestCase):
@@ -99,6 +100,32 @@ class TestGitHubDeploy(unittest.TestCase):
         )
         gh_deploy.gh_deploy(config)
 
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version')
+    @mock.patch('mkdocs.commands.gh_deploy.ghp_import.ghp_import', return_value=(True, ''))
+    def test_deploy_ignore_version_default(self, mock_import, check_version, get_remote, get_sha, is_repo):
+
+        config = load_config(
+            remote_branch='test',
+        )
+        gh_deploy.gh_deploy(config)
+        check_version.assert_called_once()
+
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version')
+    @mock.patch('mkdocs.commands.gh_deploy.ghp_import.ghp_import', return_value=(True, ''))
+    def test_deploy_ignore_version(self, mock_import, check_version, get_remote, get_sha, is_repo):
+
+        config = load_config(
+            remote_branch='test',
+        )
+        gh_deploy.gh_deploy(config, ignore_version=True)
+        check_version.assert_not_called()
+
     @mock.patch('mkdocs.utils.ghp_import.ghp_import')
     @mock.patch('mkdocs.commands.gh_deploy.log')
     def test_deploy_error(self, mock_log, mock_import):
@@ -112,3 +139,43 @@ class TestGitHubDeploy(unittest.TestCase):
         self.assertRaises(SystemExit, gh_deploy.gh_deploy, config)
         mock_log.error.assert_called_once_with('Failed to deploy to GitHub with error: \n%s',
                                                error_string)
+
+
+class TestGitHubDeployLogs(LogTestCase):
+
+    @mock.patch('subprocess.Popen')
+    def test_mkdocs_newer(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'Deployed 12345678 with MkDocs version: 0.1.2\n', b'')
+
+        with self.assertLogs('mkdocs', level='INFO') as cm:
+            gh_deploy._check_version('gh-pages')
+        self.assertEqual(
+            cm.output, ['INFO:mkdocs.commands.gh_deploy:Previous deployment was done with MkDocs '
+                        'version 0.1.2; you are deploying with a newer version ({})'.format(__version__)]
+        )
+
+    @mock.patch('subprocess.Popen')
+    def test_mkdocs_older(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'Deployed 12345678 with MkDocs version: 10.1.2\n', b'')
+
+        with self.assertLogs('mkdocs', level='ERROR') as cm:
+            self.assertRaises(SystemExit, gh_deploy._check_version, 'gh-pages')
+        self.assertEqual(
+            cm.output, ['ERROR:mkdocs.commands.gh_deploy:Deployment terminated: Previous deployment was made with '
+                        'MkDocs version 10.1.2; you are attempting to deploy with an older version ({}). Use '
+                        '--ignore-version to deploy anyway.'.format(__version__)]
+        )
+
+    @mock.patch('subprocess.Popen')
+    def test_version_unknown(self, mock_popeno):
+
+        mock_popeno().communicate.return_value = (b'No version specified\n', b'')
+
+        with self.assertLogs('mkdocs', level='WARNING') as cm:
+            gh_deploy._check_version('gh-pages')
+        self.assertEqual(
+            cm.output,
+            ['WARNING:mkdocs.commands.gh_deploy:Version check skipped: No version specificed in previous deployment.']
+        )