Repository: mkdocs/mkdocs

Issue:
Improve error message for missing Markdown extensions
I just had to install that manually.



Discussion:
Pymdown-extensions is not a required set of extensions as Mkdocs works great without them.  Those extensions are just extras you can install if you like one or more of the extensions.  I use them, but that is also because I wrote them :).

Ah, because I use `pymdownx.superfences` explicitly! Maybe you could catch the import error and promt the user nicely for installing pymdown-extensions?

Yeah, that is really a Python Markdown thing.  It is set to fail on extension import error.

Yeah, okay - let's show a better error here if we can. Even if it just a nicer error that states something like this: "Your mkdocs.yml has a python-markdown extension that you don't seem to have installed locally"

It is surprisingly difficult for us to tell users what package they need to install, because: Python packaging :smile: 

Right. You are wrapping a library, which raises an  `ImportError` when given a bad (or uninstalled) extension name. The tricky part is that MkDocs does not preload all of the extensions, but only passes the list of extension names on to Markdown on page build (and this happens every time for every single page built). I see three options:
1. Wrap [`utils.convert_markdown`](https://github.com/mkdocs/mkdocs/blob/master/mkdocs/utils/__init__.py#L327) in a try/except block and on `ImportError` stop the build process returning a friendly error. The error would occur on the first page being built. This could result in a "dirty" partially built `site_dir`. 
2. Have Mkdocs load the extensions itself (as part of the config validation) and raise errors then. Markdown will accept instances of extensions in addition to string names, so then just pass the instances to Markdown. Of course, this replicates some of the code already in Markdown and could be subject to change in future releases.
3. Create a throwaway instance of the Markdown class in the config validation step and have validation fail on error. It has to be a throwaway instance because each individual page needs its own custom version of the internal (to MkDocs) RealativePathExtension (see [mkdocs/commands/build.py#L28](https://github.com/mkdocs/mkdocs/blob/master/mkdocs/commands/build.py#L28)). Therefore, MkDocs can't just create one Markdown instance and reuse it for every page. To be clear, I would expect the throwaway instance to not include the RealativePathExtension. We simply need to validate that all user provided extension names are loadable/importable.

My suggestion would be 1 or 3 above. There is no mechanism in place within MkDocs to handle an error raised during build. All the error handling is relegated to the config validation, which is why I mention options 2 and 3 (not to mention the potential for a "dirty" site dir in option 1). Option 3 sees like the best fix to me.

> It is surprisingly difficult for us to tell users what package they need to install, because: Python packaging 

Also because many times the user simply spelled it wrong, in which case nothing needs to be installed. However, it is nearly impossible to tell which it is and writing a "friendly" error message which covers both is tricky.

A potential problem just occurred to me for both options 1 and 3 above. The error message provided by Markdown will need to be parsed to get the specific extension name which raised the error. You could just pass the message on, but that may not be "friendly".

However, I don't recommend option 2 as the upcoming version 3 of the Markdown lib will be switching to using SetupTools entry points to load extensions by name. An option 2 solution will not work across Python-Markdown versions 2.x and 3.x.

> switching to using SetupTools entry points to load extensions by name

Neat!

I have a working solution for option 3 above. If an extension `foo` is listed in the config, I get this output:

```sh
$ mkdocs build
ERROR   -  Config value: 'markdown_extensions'. Error: Failed loading extension 'foo' from 'foo', 'markdown.extensions.foo' or 'mdx_foo'

Aborted with 1 Configuration Errors!
```

The final part at the end of the message (after `Error: `) is directly from Markdown's error message. We don't tell the user to "install" the extension because we don't know if the extension isn't installed or if they spelled it wrong. The important thing is that it's clear that the problem is related to the `markdown_extensions` config value `foo`. The user now understands that they need to work out why the Markdown extension `foo` does't work. Connecting the error to the `markdown_extensions` config  would not have been possible with option 1, but we get it for free with option 3.

Resolving patch:
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -3,6 +3,7 @@
 from collections import Sequence
 import os
 from collections import namedtuple
+import markdown
 
 from mkdocs import utils, theme, plugins
 from mkdocs.config.base import Config, ValidationError
@@ -629,7 +630,16 @@ def run_validation(self, value):
                 extensions.append(item)
             else:
                 raise ValidationError('Invalid Markdown Extensions configuration')
-        return utils.reduce_list(self.builtins + extensions)
+
+        extensions = utils.reduce_list(self.builtins + extensions)
+
+        # Confirm that Markdown considers extensions to be valid
+        try:
+            markdown.Markdown(extensions=extensions, extension_configs=self.configdata)
+        except Exception as e:
+            raise ValidationError(e.args[0])
+
+        return extensions
 
     def post_validation(self, config, key_name):
         config[self.configkey] = self.configdata


Test patch:
diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -186,8 +186,9 @@ def test_build_defaults(self, mock_build, mock_load_config):
         logger = logging.getLogger('mkdocs')
         self.assertEqual(logger.level, logging.INFO)
 
+    @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
-    def test_build_clean(self, mock_build):
+    def test_build_clean(self, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['build', '--clean'], catch_exceptions=False)
@@ -198,8 +199,9 @@ def test_build_clean(self, mock_build):
         self.assertTrue('dirty' in kwargs)
         self.assertFalse(kwargs['dirty'])
 
+    @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
-    def test_build_dirty(self, mock_build):
+    def test_build_dirty(self, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['build', '--dirty'], catch_exceptions=False)
@@ -296,8 +298,9 @@ def test_build_site_dir(self, mock_build, mock_load_config):
             site_dir='custom'
         )
 
+    @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
-    def test_build_verbose(self, mock_build):
+    def test_build_verbose(self, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['build', '--verbose'], catch_exceptions=False)
@@ -307,8 +310,9 @@ def test_build_verbose(self, mock_build):
         logger = logging.getLogger('mkdocs')
         self.assertEqual(logger.level, logging.DEBUG)
 
+    @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
-    def test_build_quiet(self, mock_build):
+    def test_build_quiet(self, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['build', '--quiet'], catch_exceptions=False)
@@ -352,9 +356,10 @@ def test_gh_deploy_defaults(self, mock_gh_deploy, mock_build, mock_load_config):
             remote_name=None
         )
 
+    @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_clean(self, mock_gh_deploy, mock_build):
+    def test_gh_deploy_clean(self, mock_gh_deploy, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['gh-deploy', '--clean'], catch_exceptions=False)
@@ -366,9 +371,10 @@ def test_gh_deploy_clean(self, mock_gh_deploy, mock_build):
         self.assertTrue('dirty' in kwargs)
         self.assertFalse(kwargs['dirty'])
 
+    @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_dirty(self, mock_gh_deploy, mock_build):
+    def test_gh_deploy_dirty(self, mock_gh_deploy, mock_build, mock_load_config):
 
         result = self.runner.invoke(
             cli.cli, ['gh-deploy', '--dirty'], catch_exceptions=False)
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -2,6 +2,7 @@
 
 import os
 import unittest
+from mock import patch
 
 import mkdocs
 from mkdocs import utils
@@ -491,7 +492,8 @@ def test_defined(self):
 
 class MarkdownExtensionsTest(unittest.TestCase):
 
-    def test_simple_list(self):
+    @patch('markdown.Markdown')
+    def test_simple_list(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': ['foo', 'bar']
@@ -503,7 +505,8 @@ def test_simple_list(self):
             'mdx_configs': {}
         }, config)
 
-    def test_list_dicts(self):
+    @patch('markdown.Markdown')
+    def test_list_dicts(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': [
@@ -522,7 +525,8 @@ def test_list_dicts(self):
             }
         }, config)
 
-    def test_mixed_list(self):
+    @patch('markdown.Markdown')
+    def test_mixed_list(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': [
@@ -539,7 +543,8 @@ def test_mixed_list(self):
             }
         }, config)
 
-    def test_builtins(self):
+    @patch('markdown.Markdown')
+    def test_builtins(self, mockMd):
         option = config_options.MarkdownExtensions(builtins=['meta', 'toc'])
         config = {
             'markdown_extensions': ['foo', 'bar']
@@ -577,7 +582,8 @@ def test_builtins_config(self):
             'mdx_configs': {'toc': {'permalink': True}}
         }, config)
 
-    def test_configkey(self):
+    @patch('markdown.Markdown')
+    def test_configkey(self, mockMd):
         option = config_options.MarkdownExtensions(configkey='bar')
         config = {
             'markdown_extensions': [
@@ -605,12 +611,14 @@ def test_none(self):
             'mdx_configs': {}
         }, config)
 
-    def test_not_list(self):
+    @patch('markdown.Markdown')
+    def test_not_list(self, mockMd):
         option = config_options.MarkdownExtensions()
         self.assertRaises(config_options.ValidationError,
                           option.validate, 'not a list')
 
-    def test_invalid_config_option(self):
+    @patch('markdown.Markdown')
+    def test_invalid_config_option(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': [
@@ -622,7 +630,8 @@ def test_invalid_config_option(self):
             option.validate, config['markdown_extensions']
         )
 
-    def test_invalid_config_item(self):
+    @patch('markdown.Markdown')
+    def test_invalid_config_item(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': [
@@ -634,7 +643,8 @@ def test_invalid_config_item(self):
             option.validate, config['markdown_extensions']
         )
 
-    def test_invalid_dict_item(self):
+    @patch('markdown.Markdown')
+    def test_invalid_dict_item(self, mockMd):
         option = config_options.MarkdownExtensions()
         config = {
             'markdown_extensions': [
@@ -645,3 +655,13 @@ def test_invalid_dict_item(self):
             config_options.ValidationError,
             option.validate, config['markdown_extensions']
         )
+
+    def test_unknown_extension(self):
+        option = config_options.MarkdownExtensions()
+        config = {
+            'markdown_extensions': ['unknown']
+        }
+        self.assertRaises(
+            config_options.ValidationError,
+            option.validate, config['markdown_extensions']
+        )
diff --git a/mkdocs/tests/gh_deploy_tests.py b/mkdocs/tests/gh_deploy_tests.py
--- a/mkdocs/tests/gh_deploy_tests.py
+++ b/mkdocs/tests/gh_deploy_tests.py
@@ -3,7 +3,7 @@
 import unittest
 import mock
 
-from mkdocs.config import load_config
+from mkdocs.tests.base import load_config
 from mkdocs.commands import gh_deploy