Repository: mkdocs/mkdocs

Issue:
Config.validate keeps 'mdx_configs' values across different instances
```python
import mkdocs.config

# 1. OK
conf = mkdocs.config.Config(mkdocs.config.DEFAULT_SCHEMA)
conf.load_dict({'site_name': 'foo'})
conf.validate()
assert conf['mdx_configs'].get('toc') == None

# 2. OK
conf = mkdocs.config.Config(mkdocs.config.DEFAULT_SCHEMA)
conf.load_dict({'site_name': 'foo', 'markdown_extensions': [{"toc": {"permalink": "aaa"}}]})
conf.validate()
assert conf['mdx_configs'].get('toc') == {'permalink': 'aaa'}

# 3. Identical to 1 but not OK.
conf = mkdocs.config.Config(mkdocs.config.DEFAULT_SCHEMA)
conf.load_dict({'site_name': 'foo'})
conf.validate()
assert conf['mdx_configs'].get('toc') == None
# fails, actually is {'permalink': 'aaa'}
```


Discussion:
It took me a couple minutes but I figured out the issue. The data is not shared across instances of the `mkdocs.config.config_options.MarkdownExtensions` class, however, because you are using `mkdocs.config.DEFAULT_SCHEMA` each time, then the same instance is being reused each time. The fix you provide in #2290 certainly would address that, but  I wonder if we could fix it elsewhere. After all, that fix is non-intuitive and the sort of problem I would expect to get repeated in the future (either with this option or some other options).

Of course, this matters when using the live reload server. Each reload also reloads and validates the config. A change to the config should be reflected in the reload. I wonder if perhaps we need to change the way we are loading the `DEFAULT_SCHEMA` so that each reload gives us a new instance of each config option class. The tricky part is that the existing API is already in use by plugins, so we need to be careful not to break backward compatibility here.
Perhaps we should add some sort of `reset` to each config option class. Of course, some options won't need it, but for those that do, each reload of the config would call the reset, which would reset any variables which hold data. That way it would just be an add-on feature and wouldn't break existing plugins. A do-nothing `reset` method would be defined on the base class so all subclasses would have it. Then we simply ensure it is called on each reload.
I don't think I agree with your assessment that this is a general / likely recurring problem. Here it really is specific to the way `mkdocs.config.config_options.MarkdownExtensions` operates. It has a one-of-a-kind interaction between two config keys and the implementation just forgets that an instance of OptionallyRequired doesn't represent a value, but rather a slot for values, and so instance members can't be used for purposes specific to one particular value. While that's a little confusing, I think that this rule isn't too difficult to follow and probably has no other violations (and the reason that made it prone to violation here is specific to the 2-field interaction).

I also see that good care was taken not to accidentally mutate defaults from a schema, which could've been a whole another class of bugs and is what I suspected initially.

Anyway, so how *could* we make this less confusing? Make those instances actually act as classes that can themselves be instantiated? Are we delving into metaclasses?

Let me just cut myself short there,... from what I said above I just think that this doesn't require any significant rework.

And sure, you could introduce this `reset`, with the sole user being `MarkdownExtensions`. But that does nothing towards detecting preventing such bugs anyway.
> And sure, you could introduce this `reset`, with the sole user being `MarkdownExtensions`. But that does nothing towards detecting preventing such bugs anyway.

The thinking was that potential future options or options for third party plugins could also run into this issue. True, the classes would need to make use of the `rest` method to get the benefits, but at least we would have an official API for it. In reality, the fix in #2290 does a "reset" as the first step of the `run_validation` method., but it is not some official API.

However, it is probably better to not even need to consider that when creating option classes. One simple solution that occurs to me is to regenerate the scheme each time. Therefore, in `mkdocs.config.default`, instead of having:

```python
DEFAULT_SCHEME = (
    ...
)
```

we would have:

```python
def get_scheme():
    return (
        ...
    )
```

Then where we currently make reference to `mkdocs.config.DEFAULT_SCHEME`, we would instead use `mkdocs.config.default.get_scheme()`. This would ensure that a new instance of each config option class would be used each time that the config is reloaded.

Resolving patch:
diff --git a/mkdocs/config/__init__.py b/mkdocs/config/__init__.py
--- a/mkdocs/config/__init__.py
+++ b/mkdocs/config/__init__.py
@@ -1,6 +1,4 @@
 from mkdocs.config.base import load_config, Config
-from mkdocs.config.defaults import DEFAULT_SCHEMA
 
 __all__ = [load_config.__name__,
-           Config.__name__,
-           'DEFAULT_SCHEMA']
+           Config.__name__]
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -187,8 +187,8 @@ def load_config(config_file=None, **kwargs):
     options['config_file_path'] = getattr(config_file, 'name', '')
 
     # Initialise the config with the default schema .
-    from mkdocs import config
-    cfg = Config(schema=config.DEFAULT_SCHEMA, config_file_path=options['config_file_path'])
+    from mkdocs.config.defaults import get_schema
+    cfg = Config(schema=get_schema(), config_file_path=options['config_file_path'])
     # First load the config file
     cfg.load_file(config_file)
     # Then load the options to overwrite anything in the config.
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -8,107 +8,109 @@
 # 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_SCHEMA = (
-
-    # Reserved for internal use, stores the mkdocs.yml config file.
-    ('config_file_path', config_options.Type(str)),
-
-    # The title to use for the documentation
-    ('site_name', config_options.Type(str, required=True)),
-
-    # Defines the structure of the navigation.
-    ('nav', config_options.Nav()),
-    # TODO: remove this when the `pages` config setting is fully deprecated.
-    ('pages', config_options.Nav()),
-
-    # The full URL to where the documentation will be hosted
-    ('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)),
-    # The name of the author to add to the HTML meta tags
-    ('site_author', config_options.Type(str)),
-
-    # The MkDocs theme for the documentation.
-    ('theme', config_options.Theme(default='mkdocs')),
-
-    # The directory containing the documentation markdown.
-    ('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')),
-
-    # A copyright notice to add to the footer of documentation.
-    ('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)),
-
-    # The address on which to serve the live reloading docs server.
-    ('dev_addr', config_options.IpAddress(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)),
-
-    # Specify a link to the project source repo to be included
-    # in the documentation pages.
-    ('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
-    # "GitHub", "Bitbucket" or "GitLab" for known url or Hostname
-    # for unknown urls.
-    ('repo_name', config_options.Type(str)),
-
-    # Specify a URI to the docs dir in the project source repo, relative to the
-    # repo_url. When set, a link directly to the page in the source repo will
-    # be added to the generated HTML. If repo_url is not set also, this option
-    # is ignored.
-    ('edit_uri', config_options.Type(str)),
-
-    # Specify which css or javascript files from the docs directory should be
-    # additionally included in the site.
-    ('extra_css', config_options.Type(list, default=[])),
-    ('extra_javascript', config_options.Type(list, default=[])),
-
-    # Similar to the above, but each template (HTML or XML) will be build with
-    # Jinja2 and the global context.
-    ('extra_templates', config_options.Type(list, default=[])),
-
-    # PyMarkdown extension names.
-    ('markdown_extensions', config_options.MarkdownExtensions(
-        builtins=['toc', 'tables', 'fenced_code'],
-        configkey='mdx_configs', default=[])),
-
-    # PyMarkdown Extension Configs. For internal use only.
-    ('mdx_configs', config_options.Private()),
-
-    # 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)),
-
-    # the remote branch to commit to when using gh-deploy
-    ('remote_branch', config_options.Type(
-        str, default='gh-pages')),
-
-    # the remote name to push to when using gh-deploy
-    ('remote_name', config_options.Type(str, default='origin')),
-
-    # extra is a mapping/dictionary of data that is passed to the template.
-    # This allows template authors to require extra configuration that not
-    # relevant to all themes and doesn't need to be explicitly supported by
-    # MkDocs itself. A good example here would be including the current
-    # project version.
-    ('extra', config_options.SubConfig()),
-
-    # a list of plugins. Each item may contain a string name or a key value pair.
-    # A key value pair should be the string name (as the key) and a dict of config
-    # options (as the value).
-    ('plugins', config_options.Plugins(default=['search'])),
-)
+
+def get_schema():
+    return (
+
+        # Reserved for internal use, stores the mkdocs.yml config file.
+        ('config_file_path', config_options.Type(str)),
+
+        # The title to use for the documentation
+        ('site_name', config_options.Type(str, required=True)),
+
+        # Defines the structure of the navigation.
+        ('nav', config_options.Nav()),
+        # TODO: remove this when the `pages` config setting is fully deprecated.
+        ('pages', config_options.Nav()),
+
+        # The full URL to where the documentation will be hosted
+        ('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)),
+        # The name of the author to add to the HTML meta tags
+        ('site_author', config_options.Type(str)),
+
+        # The MkDocs theme for the documentation.
+        ('theme', config_options.Theme(default='mkdocs')),
+
+        # The directory containing the documentation markdown.
+        ('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')),
+
+        # A copyright notice to add to the footer of documentation.
+        ('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)),
+
+        # The address on which to serve the live reloading docs server.
+        ('dev_addr', config_options.IpAddress(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)),
+
+        # Specify a link to the project source repo to be included
+        # in the documentation pages.
+        ('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
+        # "GitHub", "Bitbucket" or "GitLab" for known url or Hostname
+        # for unknown urls.
+        ('repo_name', config_options.Type(str)),
+
+        # Specify a URI to the docs dir in the project source repo, relative to the
+        # repo_url. When set, a link directly to the page in the source repo will
+        # be added to the generated HTML. If repo_url is not set also, this option
+        # is ignored.
+        ('edit_uri', config_options.Type(str)),
+
+        # Specify which css or javascript files from the docs directory should be
+        # additionally included in the site.
+        ('extra_css', config_options.Type(list, default=[])),
+        ('extra_javascript', config_options.Type(list, default=[])),
+
+        # Similar to the above, but each template (HTML or XML) will be build with
+        # Jinja2 and the global context.
+        ('extra_templates', config_options.Type(list, default=[])),
+
+        # PyMarkdown extension names.
+        ('markdown_extensions', config_options.MarkdownExtensions(
+            builtins=['toc', 'tables', 'fenced_code'],
+            configkey='mdx_configs', default=[])),
+
+        # PyMarkdown Extension Configs. For internal use only.
+        ('mdx_configs', config_options.Private()),
+
+        # 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)),
+
+        # the remote branch to commit to when using gh-deploy
+        ('remote_branch', config_options.Type(
+            str, default='gh-pages')),
+
+        # the remote name to push to when using gh-deploy
+        ('remote_name', config_options.Type(str, default='origin')),
+
+        # extra is a mapping/dictionary of data that is passed to the template.
+        # This allows template authors to require extra configuration that not
+        # relevant to all themes and doesn't need to be explicitly supported by
+        # MkDocs itself. A good example here would be including the current
+        # project version.
+        ('extra', config_options.SubConfig()),
+
+        # a list of plugins. Each item may contain a string name or a key value pair.
+        # A key value pair should be the string name (as the key) and a dict of config
+        # options (as the value).
+        ('plugins', config_options.Plugins(default=['search'])),
+    )


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -32,7 +32,7 @@ def load_config(**cfg):
     if 'docs_dir' not in cfg:
         # Point to an actual dir to avoid a 'does not exist' error on validation.
         cfg['docs_dir'] = os.path.join(path_base, 'docs')
-    conf = config.Config(schema=config.DEFAULT_SCHEMA, config_file_path=cfg['config_file_path'])
+    conf = config.Config(schema=config.defaults.get_schema(), config_file_path=cfg['config_file_path'])
     conf.load_dict(cfg)
 
     errors_warnings = conf.validate()
diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py
--- a/mkdocs/tests/config/base_tests.py
+++ b/mkdocs/tests/config/base_tests.py
@@ -12,7 +12,7 @@ class ConfigBaseTests(unittest.TestCase):
 
     def test_unrecognised_keys(self):
 
-        c = base.Config(schema=defaults.DEFAULT_SCHEMA)
+        c = base.Config(schema=defaults.get_schema())
         c.load_dict({
             'not_a_valid_config_option': "test"
         })
@@ -26,7 +26,7 @@ def test_unrecognised_keys(self):
 
     def test_missing_required(self):
 
-        c = base.Config(schema=defaults.DEFAULT_SCHEMA)
+        c = base.Config(schema=defaults.get_schema())
 
         errors, warnings = c.validate()
 
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -8,6 +8,7 @@
 import mkdocs
 from mkdocs import config
 from mkdocs.config import config_options
+from mkdocs.config import defaults
 from mkdocs.exceptions import ConfigurationError
 from mkdocs.tests.base import dedent
 
@@ -20,7 +21,7 @@ def load_missing_config():
         self.assertRaises(ConfigurationError, load_missing_config)
 
     def test_missing_site_name(self):
-        c = config.Config(schema=config.DEFAULT_SCHEMA)
+        c = config.Config(schema=defaults.get_schema())
         c.load_dict({})
         errors, warnings = c.validate()
         self.assertEqual(len(errors), 1)
@@ -201,7 +202,7 @@ def test_theme(self):
                 self.assertEqual({k: c['theme'][k] for k in iter(c['theme'])}, result['vars'])
 
     def test_empty_nav(self):
-        conf = config.Config(schema=config.DEFAULT_SCHEMA)
+        conf = config.Config(schema=defaults.get_schema())
         conf.load_dict({
             'site_name': 'Example',
             'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
@@ -211,7 +212,7 @@ def test_empty_nav(self):
 
     def test_copy_pages_to_nav(self):
         # TODO: remove this when pages config setting is fully deprecated.
-        conf = config.Config(schema=config.DEFAULT_SCHEMA)
+        conf = config.Config(schema=defaults.get_schema())
         conf.load_dict({
             'site_name': 'Example',
             'pages': ['index.md', 'about.md'],
@@ -222,7 +223,7 @@ def test_copy_pages_to_nav(self):
 
     def test_dont_overwrite_nav_with_pages(self):
         # TODO: remove this when pages config setting is fully deprecated.
-        conf = config.Config(schema=config.DEFAULT_SCHEMA)
+        conf = config.Config(schema=defaults.get_schema())
         conf.load_dict({
             'site_name': 'Example',
             'pages': ['index.md', 'about.md'],
@@ -266,3 +267,19 @@ def test_doc_dir_in_site_dir(self):
 
             self.assertEqual(len(errors), 1)
             self.assertEqual(warnings, [])
+
+    def testConfigInstancesUnique(self):
+        conf = mkdocs.config.Config(mkdocs.config.defaults.get_schema())
+        conf.load_dict({'site_name': 'foo'})
+        conf.validate()
+        self.assertIsNone(conf['mdx_configs'].get('toc'))
+
+        conf = mkdocs.config.Config(mkdocs.config.defaults.get_schema())
+        conf.load_dict({'site_name': 'foo', 'markdown_extensions': [{"toc": {"permalink": "aaa"}}]})
+        conf.validate()
+        self.assertEqual(conf['mdx_configs'].get('toc'), {'permalink': 'aaa'})
+
+        conf = mkdocs.config.Config(mkdocs.config.defaults.get_schema())
+        conf.load_dict({'site_name': 'foo'})
+        conf.validate()
+        self.assertIsNone(conf['mdx_configs'].get('toc'))