Repository: mkdocs/mkdocs
Pull request #507 (2015-05-09 17:28:08)

Change the config schmea from a dict to a tuple
With a tuple we have a guaranteed order which makes the post validate
more predictable. Otherwise, if a post validate required the result of
another there was no way to do this. Now the prerequisties just need to
be higher in the config.

This change also renames the config option used internally to save the 
users config file.


Patch:
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index 5f829cda..675a71d8 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -23,25 +23,28 @@ class Config(six.moves.UserDict):
         """
 
         self._schema = schema
+        self._schema_keys = set(dict(schema).keys())
         self.data = {}
 
         self.set_defaults()
         self.user_configs = []
 
+        self.config_file_path = None
+
     def set_defaults(self):
         """
         Set the base config by going through each validator and getting the
         default if it has one.
         """
 
-        for key, config_option in self._schema.items():
+        for key, config_option in self._schema:
             self[key] = config_option.default
 
     def _validate(self):
 
         failed, warnings = [], []
 
-        for key, config_option in self._schema.items():
+        for key, config_option in self._schema:
             try:
                 value = self.get(key)
                 self[key] = config_option.validate(value)
@@ -49,7 +52,7 @@ class Config(six.moves.UserDict):
             except config_options.ValidationError as e:
                 failed.append((key, str(e)))
 
-        for key in (set(self.keys()) - set(self._schema.keys())):
+        for key in (set(self.keys()) - self._schema_keys):
             warnings.append((
                 key, "Unrecognised configuration name: {0}".format(key)
             ))
@@ -58,8 +61,7 @@ class Config(six.moves.UserDict):
 
     def _post_validate(self):
 
-        for key in self._schema.keys():
-            config_option = self._schema[key]
+        for key, config_option in self._schema:
             config_option.post_validation(self, key_name=key)
 
     def validate(self):
@@ -82,6 +84,7 @@ class Config(six.moves.UserDict):
         self.data.update(patch)
 
     def load_file(self, config_file):
+        self.config_file_path = config_file.name
         return self.update(utils.yaml_load(config_file))
 
     def load_dict(self, data):
@@ -92,7 +95,9 @@ def _open_config_file(config_file):
 
     # Default to the standard config filename.
     if config_file is None:
-        config_file = 'mkdocs.yml'
+        config_file = os.path.abspath('mkdocs.yml')
+
+    log.debug("Loading configuration file: %s", config_file)
 
     # If it is a string, we can assume it is a path and attempt to open it.
     if isinstance(config_file, six.string_types):
@@ -124,10 +129,11 @@ def load_config(config_file=None, **kwargs):
         if value is None:
             options.pop(key)
 
-    options['config'] = config_file = _open_config_file(config_file)
+    config_file = _open_config_file(config_file)
+    options['config_file_path'] = config_file.name
 
     # Initialise the config with the default schema .
-    config = Config(schema=defaults.DEFAULT_CONFIG)
+    config = Config(schema=defaults.DEFAULT_SCHEMA)
     # First load the config file
     config.load_file(config_file)
     # Then load the options to overwrite anything in the config.
@@ -143,4 +149,8 @@ def load_config(config_file=None, **kwargs):
             log.error("%s - %s", config_name, error)
         raise exceptions.ConfigurationError("Errors found in the config file.")
 
+    for key, value in config.items():
+
+        log.debug("Config value: %s = %r", key, value)
+
     return config
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index b7b16c48..b3b14146 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -1,98 +1,99 @@
-import io
-
-import six
-
 from mkdocs import utils
 from mkdocs.config import config_options
 
-if six.PY2:
-    config_type = file
-else:
-    config_type = io.IOBase
+# NOTE: The order here is important. During validation some config options
+# depend on others. So, if config option A depends on B, then A should be
+# listed higher in the schema.
+
+# Once we drop Python 2.6 support, this could be an OrderedDict, however, it
+# isn't really needed either as we always sequentially process the schema other
+# than at initialisation when we grab the full set of keys for convenience.
 
-DEFAULT_CONFIG = {
+DEFAULT_SCHEMA = (
 
     # Reserved for internal use, stores the mkdocs.yml config file.
-    'config': config_options.Type(config_type),
+    ('config_file_path', config_options.Type(str)),
 
     # The title to use for the documentation
-    'site_name': config_options.Type(str, required=True),
+    ('site_name', config_options.Type(str, required=True)),
 
     # Defines the structure of the navigation and which markdown files are
     # included in the build.
-    'pages': config_options.Pages(),
+    ('pages', config_options.Pages()),
 
     # The full URL to where the documentation will be hosted
-    'site_url': config_options.URL(),
+    ('site_url', config_options.URL()),
 
     # A description for the documentation project that will be added to the
     # HTML meta tags.
-    'site_description': config_options.Type(str),
+    ('site_description', config_options.Type(str)),
     # The name of the author to add to the HTML meta tags
-    'site_author': config_options.Type(str),
+    ('site_author', config_options.Type(str)),
 
     # The path to the favicon for a site
-    'site_favicon': config_options.Type(str),
+    ('site_favicon', config_options.Type(str)),
 
     # The MkDocs theme for the documentation.
-    'theme': config_options.Theme(default='mkdocs'),
+    ('theme', config_options.Theme(default='mkdocs')),
 
     # The directory containing the documentation markdown.
-    'docs_dir': config_options.Dir(default='docs', exists=True),
+    ('docs_dir', config_options.Dir(default='docs', exists=True)),
 
     # The directory where the site will be built to
-    'site_dir': config_options.SiteDir(default='site'),
+    ('site_dir', config_options.SiteDir(default='site')),
 
     # The directory of a theme to use if not using one of the builtin MkDocs
     # themes.
-    'theme_dir': config_options.ThemeDir(exists=True),
+    ('theme_dir', config_options.ThemeDir(exists=True)),
 
     # A copyright notice to add to the footer of documentation.
-    'copyright': config_options.Type(str),
+    ('copyright', config_options.Type(str)),
 
     # set of values for Google analytics containing the account IO and domain,
-    # this should look like: ['UA-27795084-5', 'mkdocs.org']
-    'google_analytics': config_options.Type(list, length=2),
+    # this should look like, ['UA-27795084-5', 'mkdocs.org']
+    ('google_analytics', config_options.Type(list, length=2)),
 
     # The address on which to serve the live reloading docs server.
-    'dev_addr': config_options.Type(str, default='127.0.0.1:8000'),
+    ('dev_addr', config_options.Type(str, default='127.0.0.1:8000')),
 
     # If `True`, use `<page_name>/index.hmtl` style files with hyperlinks to
     # the directory.If `False`, use `<page_name>.html style file with
     # hyperlinks to the file.
     # True generates nicer URLs, but False is useful if browsing the output on
     # a filesystem.
-    'use_directory_urls': config_options.Type(bool, default=True),
+    ('use_directory_urls', config_options.Type(bool, default=True)),
 
     # Specify a link to the project source repo to be included
     # in the documentation pages.
-    'repo_url': config_options.RepoURL(),
+    ('repo_url', config_options.RepoURL()),
 
     # A name to use for the link to the project source repo.
-    # Default: If repo_url is unset then None, otherwise
+    # Default, If repo_url is unset then None, otherwise
     # "GitHub" or "Bitbucket" for known url or Hostname for unknown urls.
-    'repo_name': config_options.Type(str),
+    ('repo_name', config_options.Type(str)),
 
     # Specify which css or javascript files from the docs directory should be
-    # additionally included in the site. Default: List of all .css and .js
+    # additionally included in the site. Default, List of all .css and .js
     # files in the docs dir.
-    'extra_css': config_options.Extras(file_match=utils.is_css_file),
-    'extra_javascript': config_options.Extras(
-        file_match=utils.is_javascript_file),
+    ('extra_css', config_options.Extras(file_match=utils.is_css_file)),
+    ('extra_javascript', config_options.Extras(
+        file_match=utils.is_javascript_file)),
 
     # Similar to the above, but each template (HTML or XML) will be build with
     # Jinja2 and the global context.
-    'extra_templates': config_options.Extras(file_match=utils.is_template_file),
+    ('extra_templates', config_options.Extras(
+        file_match=utils.is_template_file)),
 
     # Determine if the site should include the nav and next/prev elements.
-    # Default: True if the site has more than one page, False otherwise.
-    'include_nav': config_options.NumPages(),
-    'include_next_prev': config_options.NumPages(),
+    # Default, True if the site has more than one page, False otherwise.
+    ('include_nav', config_options.NumPages()),
+    ('include_next_prev', config_options.NumPages()),
 
     # PyMarkdown extension names.
-    'markdown_extensions': config_options.Type((list, dict, tuple), default=()),
+    ('markdown_extensions', config_options.Type(
+        (list, dict, tuple), default=())),
 
     # enabling strict mode causes MkDocs to stop the build when a problem is
     # encountered rather than display an error.
-    'strict': config_options.Type(bool, default=False)
-}
+    ('strict', config_options.Type(bool, default=False)),
+)
diff --git a/mkdocs/serve.py b/mkdocs/serve.py
index 78210762..093697d4 100644
--- a/mkdocs/serve.py
+++ b/mkdocs/serve.py
@@ -23,7 +23,7 @@ def serve(config):
 
     # Watch the documentation files, the config file and the theme files.
     server.watch(config['docs_dir'], builder)
-    server.watch(config['config'].name)
+    server.watch(config['config_file_path'], builder)
 
     for d in config['theme_dir']:
         server.watch(d, builder)


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index b65ef4bc..7f2a04a7 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -280,7 +280,7 @@ class BuildTests(unittest.TestCase):
             os.mkdir(os.path.join(docs_dir, '.git'))
             open(os.path.join(docs_dir, '.git/hidden'), 'w').close()
 
-            conf = config_base.Config(schema=config_defaults.DEFAULT_CONFIG)
+            conf = config_base.Config(schema=config_defaults.DEFAULT_SCHEMA)
             conf.load_dict({
                 'site_name': 'Example',
                 'docs_dir': docs_dir,
diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py
index 22f20640..0d6f1f4f 100644
--- a/mkdocs/tests/config/base_tests.py
+++ b/mkdocs/tests/config/base_tests.py
@@ -10,7 +10,7 @@ class ConfigBaseTests(unittest.TestCase):
 
     def test_unrecognised_keys(self):
 
-        c = base.Config(schema=defaults.DEFAULT_CONFIG)
+        c = base.Config(schema=defaults.DEFAULT_SCHEMA)
         c.load_dict({
             'not_a_valid_config_option': "test"
         })
@@ -24,7 +24,7 @@ class ConfigBaseTests(unittest.TestCase):
 
     def test_missing_required(self):
 
-        c = base.Config(schema=defaults.DEFAULT_CONFIG)
+        c = base.Config(schema=defaults.DEFAULT_SCHEMA)
 
         failed, warnings = c.validate()
 
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index fafe1ada..44fc3d04 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -27,7 +27,7 @@ class ConfigTests(unittest.TestCase):
         self.assertRaises(ConfigurationError, load_missing_config)
 
     def test_missing_site_name(self):
-        c = base.Config(schema=defaults.DEFAULT_CONFIG)
+        c = base.Config(schema=defaults.DEFAULT_SCHEMA)
         c.load_dict({})
         errors, warings = c.validate()
         self.assertEqual([
@@ -138,9 +138,8 @@ class ConfigTests(unittest.TestCase):
                 config_file = tempfile.NamedTemporaryFile('w', delete=False)
                 config_file.write(ensure_utf(base_config % config_contents))
                 config_file.flush()
-                result = config.load_config(
-                    config_file=open(config_file.name, 'rb'))
-                self.assertEqual(result['theme_dir'], expected_result)
+                result = config.load_config(config_file=config_file.name)
+                self.assertEqual(expected_result, result['theme_dir'])
             finally:
                 try:
                     config_file.close()
@@ -154,7 +153,7 @@ class ConfigTests(unittest.TestCase):
         try:
             open(os.path.join(tmp_dir, 'index.md'), 'w').close()
             open(os.path.join(tmp_dir, 'about.md'), 'w').close()
-            conf = base.Config(schema=defaults.DEFAULT_CONFIG)
+            conf = base.Config(schema=defaults.DEFAULT_SCHEMA)
             conf.load_dict({
                 'site_name': 'Example',
                 'docs_dir': tmp_dir
@@ -173,7 +172,7 @@ class ConfigTests(unittest.TestCase):
             open(os.path.join(tmp_dir, 'sub', 'sub.md'), 'w').close()
             os.makedirs(os.path.join(tmp_dir, 'sub', 'sub2'))
             open(os.path.join(tmp_dir, 'sub', 'sub2', 'sub2.md'), 'w').close()
-            conf = base.Config(schema=defaults.DEFAULT_CONFIG)
+            conf = base.Config(schema=defaults.DEFAULT_SCHEMA)
             conf.load_dict({
                 'site_name': 'Example',
                 'docs_dir': tmp_dir
@@ -217,10 +216,11 @@ class ConfigTests(unittest.TestCase):
             patch = conf.copy()
             patch.update(test_config)
 
-            schema = defaults.DEFAULT_CONFIG.copy()
-            schema['docs_dir'] = config_options.Dir()
-
-            c = base.Config(schema=schema)
+            # Same as the default schema, but don't verify the docs_dir exists.
+            c = base.Config(schema=(
+                ('docs_dir', config_options.Dir(default='docs')),
+                ('site_dir', config_options.SiteDir(default='site')),
+            ))
             c.load_dict(patch)
 
             self.assertRaises(config_options.ValidationError, c.validate)