Repository: mkdocs/mkdocs
Pull request #2938 (2022-08-16 19:41:32)

Refactor ConfigOptions
Please review commit-by-commit.


Patch:
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index e0055dbe..e4ec9d42 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -7,9 +7,11 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.7, 3.8, 3.9, '3.10', pypy-3.7-v7.x]
+        python-version: [3.6, 3.7, 3.8, 3.9, '3.10', pypy-3.7-v7.x]
         os: [ubuntu-latest, windows-latest, macos-latest]
         include:
+          - python-version: 3.6
+            tox-env: py36
           - python-version: 3.7
             tox-env: py37
           - python-version: 3.8
@@ -22,6 +24,10 @@ jobs:
             tox-env: pypy3
         # Just to slim down the test matrix:
         exclude:
+          - python-version: 3.7
+            os: windows-latest
+          - python-version: 3.7
+            os: ubuntu-latest
           - python-version: 3.8
             os: macos-latest
           - python-version: 3.8
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
index b37c0934..a06989ca 100644
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -1,7 +1,5 @@
 #!/usr/bin/env python
 
-from __future__ import annotations
-
 import logging
 import os
 import shutil
diff --git a/mkdocs/commands/babel.py b/mkdocs/commands/babel.py
index 2ccadc28..0f530b06 100644
--- a/mkdocs/commands/babel.py
+++ b/mkdocs/commands/babel.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 from distutils.errors import DistutilsOptionError
 from os import path
 
@@ -56,7 +54,7 @@ class extract_messages(babel.extract_messages, ThemeMixin):
     def finalize_options(self):
         if not self.version:
             version = self.distribution.get_version()
-            self.version = ".".join(i for i in version.split(".") if "dev" not in i)
+            self.version = ".".join([i for i in version.split(".") if "dev" not in i])
         if not self.mapping_file:
             self.mapping_file = DEFAULT_MAPPING_FILE
         if not self.input_paths or not self.output_file:
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index d74476e6..c741a3bf 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import gzip
 import logging
 import os
@@ -316,7 +314,7 @@ def build(config, live_server=False, dirty=False):
 
         counts = warning_counter.get_counts()
         if counts:
-            msg = ', '.join(f'{v} {k.lower()}s' for k, v in counts)
+            msg = ', '.join([f'{v} {k.lower()}s' for k, v in counts])
             raise Abort(f'\nAborted with {msg} in strict mode!')
 
         log.info('Documentation built in %.2f seconds', time() - start)
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index 7c2cede0..27986529 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import os
 import re
diff --git a/mkdocs/commands/new.py b/mkdocs/commands/new.py
index d71292f9..32c906fc 100644
--- a/mkdocs/commands/new.py
+++ b/mkdocs/commands/new.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import os
 
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 4b537f74..b4fec34c 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import shutil
 import tempfile
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index a7b952c9..cf14ebfb 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -1,5 +1,4 @@
-from __future__ import annotations
-
+import functools
 import logging
 import os
 import sys
@@ -19,8 +18,17 @@ class BaseConfigOption:
         self.warnings = []
         self.default = None
 
-    def is_required(self):
-        return False
+    @property
+    def default(self):
+        try:
+            # ensure no mutable values are assigned
+            return self._default.copy()
+        except AttributeError:
+            return self._default
+
+    @default.setter
+    def default(self, value):
+        self._default = value
 
     def validate(self, value):
         return self.run_validation(value)
@@ -56,6 +64,10 @@ class ValidationError(Exception):
     """Raised during the validation process of the config on errors."""
 
 
+PlainConfigSchemaItem = Tuple[str, BaseConfigOption]
+PlainConfigSchema = Sequence[PlainConfigSchemaItem]
+
+
 class Config(UserDict):
     """
     MkDocs Configuration dict
@@ -64,9 +76,7 @@ class Config(UserDict):
     for running validation on the structure and contents.
     """
 
-    def __init__(
-        self, schema: Sequence[Tuple[str, BaseConfigOption]], config_file_path: Optional[str] = None
-    ):
+    def __init__(self, schema: PlainConfigSchema, config_file_path: Optional[str] = None):
         """
         The schema is a Python dict which maps the config name to a validator.
         """
@@ -103,7 +113,7 @@ class Config(UserDict):
             try:
                 value = self.get(key)
                 self[key] = config_option.validate(value)
-                warnings.extend((key, w) for w in config_option.warnings)
+                warnings.extend([(key, w) for w in config_option.warnings])
                 config_option.reset_warnings()
             except ValidationError as e:
                 failed.append((key, e))
@@ -120,7 +130,7 @@ class Config(UserDict):
         for key, config_option in self._schema:
             try:
                 config_option.pre_validation(self, key_name=key)
-                warnings.extend((key, w) for w in config_option.warnings)
+                warnings.extend([(key, w) for w in config_option.warnings])
                 config_option.reset_warnings()
             except ValidationError as e:
                 failed.append((key, e))
@@ -134,7 +144,7 @@ class Config(UserDict):
         for key, config_option in self._schema:
             try:
                 config_option.post_validation(self, key_name=key)
-                warnings.extend((key, w) for w in config_option.warnings)
+                warnings.extend([(key, w) for w in config_option.warnings])
                 config_option.reset_warnings()
             except ValidationError as e:
                 failed.append((key, e))
@@ -183,6 +193,17 @@ class Config(UserDict):
             )
 
 
+@functools.lru_cache(maxsize=None)
+def get_schema(cls: type) -> PlainConfigSchema:
+    """
+    Extract ConfigOptions defined in a class (used just as a container) and put them into a schema tuple.
+
+    See mkdocs/config/defaults.py for an example.
+    """
+    all_items = ((k, getattr(cls, k)) for k in dir(cls) if not k.startswith('_'))
+    return tuple((k, v) for k, v in all_items if isinstance(v, BaseConfigOption))
+
+
 @contextmanager
 def _open_config_file(config_file):
     """
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 7ea92749..eafd76ba 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -1,17 +1,16 @@
-from __future__ import annotations
-
 import ipaddress
 import os
 import sys
 import traceback
-from collections import namedtuple
-from collections.abc import Sequence
+import typing as t
+from typing import NamedTuple
 from urllib.parse import urlsplit, urlunsplit
 
 import markdown
 
 from mkdocs import plugins, theme, utils
-from mkdocs.config.base import BaseConfigOption, Config, ValidationError
+from mkdocs.config.base import BaseConfigOption, Config, PlainConfigSchemaItem, ValidationError
+from mkdocs.exceptions import ConfigurationError
 
 
 class SubConfig(BaseConfigOption):
@@ -24,7 +23,7 @@ class SubConfig(BaseConfigOption):
     enable validation with `validate=True`.
     """
 
-    def __init__(self, *config_options, validate=False):
+    def __init__(self, *config_options: PlainConfigSchemaItem, validate: bool = False):
         super().__init__()
         self.default = {}
         self.config_options = config_options
@@ -32,8 +31,11 @@ class SubConfig(BaseConfigOption):
 
     def run_validation(self, value):
         config = Config(self.config_options)
-        config.load_dict(value)
-        failed, warnings = config.validate()
+        try:
+            config.load_dict(value)
+            failed, warnings = config.validate()
+        except ConfigurationError as e:
+            raise ValidationError(str(e))
 
         if self._do_validation:
             # Capture errors and warnings
@@ -46,51 +48,17 @@ class SubConfig(BaseConfigOption):
         return config
 
 
-class ConfigItems(BaseConfigOption):
-    """
-    Config Items Option
-
-    Validates a list of mappings that all must match the same set of
-    options.
-    """
-
-    def __init__(self, *config_options, **kwargs):
-        BaseConfigOption.__init__(self)
-        self.item_config = SubConfig(*config_options)
-        self.required = kwargs.get('required', False)
-
-    def __repr__(self):
-        return f'{self.__class__.__name__}: {self.item_config}'
-
-    def run_validation(self, value):
-        if value is None:
-            if self.required:
-                raise ValidationError("Required configuration not provided.")
-            else:
-                return ()
-
-        if not isinstance(value, Sequence):
-            raise ValidationError(
-                f'Expected a sequence of mappings, but a ' f'{type(value)} was given.'
-            )
-
-        return [self.item_config.validate(item) for item in value]
-
-
 class OptionallyRequired(BaseConfigOption):
     """
     A subclass of BaseConfigOption that adds support for default values and
     required values. It is a base class for config options.
     """
 
-    def __init__(self, default=None, required=False):
+    def __init__(self, default=None, required: bool = False):
         super().__init__()
         self.default = default
         self.required = required
 
-    def is_required(self):
-        return self.required
-
     def validate(self, value):
         """
         Perform some initial validation.
@@ -102,19 +70,81 @@ class OptionallyRequired(BaseConfigOption):
 
         if value is None:
             if self.default is not None:
-                if hasattr(self.default, 'copy'):
-                    # ensure no mutable values are assigned
-                    value = self.default.copy()
-                else:
-                    value = self.default
+                value = self.default
             elif not self.required:
-                return
+                return None
             elif self.required:
                 raise ValidationError("Required configuration not provided.")
 
         return self.run_validation(value)
 
 
+class ListOfItems(BaseConfigOption):
+    """
+    Validates a homogeneous list of items.
+
+    E.g. for `config_options.ListOfItems(config_options.Type(int))` a valid item is `[1, 2, 3]`.
+    """
+
+    required = False
+
+    def __init__(self, option_type: BaseConfigOption, default=[]):
+        super().__init__()
+        self.default = default
+        self.option_type = option_type
+        self.option_type.warnings = self.warnings
+
+    def __repr__(self):
+        return f'{type(self).__name__}: {self.option_type}'
+
+    def pre_validation(self, config, key_name):
+        self._config = config
+        self._key_name = key_name
+
+    def run_validation(self, value):
+        if value is None and not self.required:
+            return self.default
+
+        if not isinstance(value, list):
+            raise ValidationError(f'Expected a list of items, but a {type(value)} was given.')
+
+        fake_config = Config(())
+        try:
+            fake_config.config_file_path = self._config.config_file_path
+        except AttributeError:
+            pass
+
+        # Emulate a config-like environment for pre_validation and post_validation.
+        parent_key_name = getattr(self, '_key_name', '')
+        fake_keys = [f'{parent_key_name}[{i}]' for i in range(len(value))]
+        fake_config.data = dict(zip(fake_keys, value))
+
+        for key_name in fake_config:
+            self.option_type.pre_validation(fake_config, key_name)
+        for key_name in fake_config:
+            # Specifically not running `validate` to avoid the OptionallyRequired effect.
+            fake_config[key_name] = self.option_type.run_validation(fake_config[key_name])
+        for key_name in fake_config:
+            self.option_type.post_validation(fake_config, key_name)
+
+        return [fake_config[k] for k in fake_keys]
+
+
+class ConfigItems(ListOfItems):
+    """
+    Config Items Option
+
+    Validates a list of mappings that all must match the same set of
+    options.
+    """
+
+    def __init__(
+        self, *config_options: PlainConfigSchemaItem, required: bool = False, validate: bool = False
+    ):
+        super().__init__(SubConfig(*config_options, validate=validate))
+        self.required = required
+
+
 class Type(OptionallyRequired):
     """
     Type Config Option
@@ -122,7 +152,7 @@ class Type(OptionallyRequired):
     Validate the type of a config option against a given Python type.
     """
 
-    def __init__(self, type_, length=None, **kwargs):
+    def __init__(self, type_, length: t.Optional[int] = None, **kwargs):
         super().__init__(**kwargs)
         self._type = type_
         self.length = length
@@ -149,8 +179,8 @@ class Choice(OptionallyRequired):
     Validate the config option against a strict set of values.
     """
 
-    def __init__(self, choices, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(self, choices, default=None, **kwargs):
+        super().__init__(default=default, **kwargs)
         try:
             length = len(choices)
         except TypeError:
@@ -158,16 +188,15 @@ class Choice(OptionallyRequired):
 
         if not length or isinstance(choices, str):
             raise ValueError(f'Expected iterable of choices, got {choices}')
+        if default is not None and default not in choices:
+            raise ValueError(f'{default!r} is not one of {choices!r}')
 
         self.choices = choices
 
     def run_validation(self, value):
         if value not in self.choices:
-            msg = f"Expected one of: {self.choices} but received: {value}"
-        else:
-            return value
-
-        raise ValidationError(msg)
+            raise ValidationError(f"Expected one of: {self.choices} but received: {value}")
+        return value
 
 
 class Deprecated(BaseConfigOption):
@@ -180,7 +209,13 @@ class Deprecated(BaseConfigOption):
     ConfigOption instance, then the value is validated against that type.
     """
 
-    def __init__(self, moved_to=None, message=None, removed=False, option_type=None):
+    def __init__(
+        self,
+        moved_to: t.Optional[str] = None,
+        message: t.Optional[str] = None,
+        removed: bool = False,
+        option_type: t.Optional[BaseConfigOption] = None,
+    ):
         super().__init__()
         self.default = None
         self.moved_to = moved_to
@@ -237,6 +272,14 @@ class Deprecated(BaseConfigOption):
         self.warnings = self.option.warnings
 
 
+class _IpAddressValue(NamedTuple):
+    host: str
+    port: int
+
+    def __str__(self):
+        return f'{self.host}:{self.port}'
+
+
 class IpAddress(OptionallyRequired):
     """
     IpAddress Config Option
@@ -262,11 +305,7 @@ class IpAddress(OptionallyRequired):
         except Exception:
             raise ValidationError(f"'{port}' is not a valid port")
 
-        class Address(namedtuple('Address', 'host port')):
-            def __str__(self):
-                return f'{self.host}:{self.port}'
-
-        return Address(host, port)
+        return _IpAddressValue(host, port)
 
     def post_validation(self, config, key_name):
         host = config[key_name].host
@@ -286,7 +325,7 @@ class URL(OptionallyRequired):
     Validate a URL by requiring a scheme is present.
     """
 
-    def __init__(self, default='', required=False, is_dir=False):
+    def __init__(self, default='', required: bool = False, is_dir: bool = False):
         self.is_dir = is_dir
         super().__init__(default, required)
 
@@ -351,7 +390,10 @@ class FilesystemObject(Type):
     Base class for options that point to filesystem objects.
     """
 
-    def __init__(self, exists=False, **kwargs):
+    existence_test = staticmethod(os.path.exists)
+    name = 'file or directory'
+
+    def __init__(self, exists: bool = False, **kwargs):
         super().__init__(type_=str, **kwargs)
         self.exists = exists
         self.config_dir = None
@@ -380,6 +422,8 @@ class Dir(FilesystemObject):
     existence_test = staticmethod(os.path.isdir)
     name = 'directory'
 
+
+class DocsDir(Dir):
     def post_validation(self, config, key_name):
         if config.config_file_path is None:
             return
@@ -404,36 +448,20 @@ class File(FilesystemObject):
     name = 'file'
 
 
-class ListOfPaths(OptionallyRequired):
+class ListOfPaths(ListOfItems):
     """
     List of Paths Config Option
 
     A list of file system paths. Raises an error if one of the paths does not exist.
-    """
 
-    def __init__(self, default=[], required=False):
-        self.config_dir = None
-        super().__init__(default, required)
+    For greater flexibility, prefer ListOfItems, e.g. to require files specifically:
 
-    def pre_validation(self, config, key_name):
-        self.config_dir = (
-            os.path.dirname(config.config_file_path) if config.config_file_path else None
-        )
+        config_options.ListOfItems(config_options.File(exists=True))
+    """
 
-    def run_validation(self, value):
-        if not isinstance(value, list):
-            raise ValidationError(f"Expected a list, got {type(value)}")
-        if len(value) == 0:
-            return
-        paths = []
-        for path in value:
-            if self.config_dir and not os.path.isabs(path):
-                path = os.path.join(self.config_dir, path)
-            if not os.path.exists(path):
-                raise ValidationError(f"The path {path} does not exist.")
-            path = os.path.abspath(path)
-            paths.append(path)
-        return paths
+    def __init__(self, default=[], required: bool = False):
+        super().__init__(FilesystemObject(exists=True), default)
+        self.required = required
 
 
 class SiteDir(Dir):
@@ -479,7 +507,7 @@ class Theme(BaseConfigOption):
         super().__init__()
         self.default = default
 
-    def validate(self, value):
+    def run_validation(self, value):
         if value is None and self.default is not None:
             value = {'name': self.default}
 
@@ -601,8 +629,14 @@ class MarkdownExtensions(OptionallyRequired):
     `configkey`. The `builtins` keyword accepts a list of extensions which cannot be overridden by
     the user. However, builtins can be duplicated to define config options for them if desired."""
 
-    def __init__(self, builtins=None, configkey='mdx_configs', **kwargs):
-        super().__init__(**kwargs)
+    def __init__(
+        self,
+        builtins: t.Optional[list] = None,
+        configkey: str = 'mdx_configs',
+        default=[],
+        **kwargs,
+    ):
+        super().__init__(default=default, **kwargs)
         self.builtins = builtins or []
         self.configkey = configkey
 
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index f5411686..723209ed 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -1,102 +1,120 @@
-from __future__ import annotations
+from mkdocs.config import base, config_options
+
+
+def get_schema():
+    return base.get_schema(_MkDocsConfig)
 
-from mkdocs.config import config_options
 
 # 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.
+class _MkDocsConfig:
+    config_file_path = config_options.Type(str)
+    """Reserved for internal use, stores the mkdocs.yml config file."""
 
-# 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 initialization when we grab the full set of keys for convenience.
+    site_name = config_options.Type(str, required=True)
+    """The title to use for the documentation."""
 
+    nav = config_options.Nav()
+    """Defines the structure of the navigation."""
+    pages = config_options.Deprecated(removed=True, moved_to='nav')
 
-def get_schema():
-    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(),
-        'pages': config_options.Deprecated(removed=True, moved_to='nav'),
-        # The full URL to where the documentation will be hosted
-        'site_url': config_options.URL(is_dir=True),
-        # 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.Deprecated(
-            message=(
-                'The configuration option {} has been deprecated and '
-                'will be removed in a future release of MkDocs. See the '
-                'options available on your theme for an alternative.'
-            ),
-            option_type=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=[]
+    site_url = config_options.URL(is_dir=True)
+    """The full URL to where the documentation will be hosted."""
+
+    site_description = config_options.Type(str)
+    """A description for the documentation project that will be added to the
+    HTML meta tags."""
+    site_author = config_options.Type(str)
+    """The name of the author to add to the HTML meta tags."""
+
+    theme = config_options.Theme(default='mkdocs')
+    """The MkDocs theme for the documentation."""
+
+    docs_dir = config_options.DocsDir(default='docs', exists=True)
+    """The directory containing the documentation markdown."""
+
+    site_dir = config_options.SiteDir(default='site')
+    """The directory where the site will be built to"""
+
+    copyright = config_options.Type(str)
+    """A copyright notice to add to the footer of documentation."""
+
+    google_analytics = config_options.Deprecated(
+        message=(
+            'The configuration option {} has been deprecated and '
+            'will be removed in a future release of MkDocs. See the '
+            'options available on your theme for an alternative.'
         ),
-        # 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']),
-        # a list of extra paths to watch while running `mkdocs serve`
-        'watch': config_options.ListOfPaths(default=[]),
-    }
-    return tuple(schema.items())
+        option_type=config_options.Type(list, length=2),
+    )
+    """set of values for Google analytics containing the account IO and domain
+    this should look like, ['UA-27795084-5', 'mkdocs.org']"""
+
+    dev_addr = config_options.IpAddress(default='127.0.0.1:8000')
+    """The address on which to serve the live reloading docs server."""
+
+    use_directory_urls = config_options.Type(bool, default=True)
+    """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."""
+
+    repo_url = config_options.RepoURL()
+    """Specify a link to the project source repo to be included
+    in the documentation pages."""
+
+    repo_name = config_options.Type(str)
+    """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."""
+
+    edit_uri = 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."""
+
+    extra_css = config_options.Type(list, default=[])
+    extra_javascript = config_options.Type(list, default=[])
+    """Specify which css or javascript files from the docs directory should be
+    additionally included in the site."""
+
+    extra_templates = config_options.Type(list, default=[])
+    """Similar to the above, but each template (HTML or XML) will be build with
+    Jinja2 and the global context."""
+
+    markdown_extensions = config_options.MarkdownExtensions(
+        builtins=['toc', 'tables', 'fenced_code'], configkey='mdx_configs'
+    )
+    """PyMarkdown extension names."""
+
+    mdx_configs = config_options.Private()
+    """PyMarkdown Extension Configs. For internal use only."""
+
+    strict = config_options.Type(bool, default=False)
+    """Enabling strict mode causes MkDocs to stop the build when a problem is
+    encountered rather than display an error."""
+
+    remote_branch = config_options.Type(str, default='gh-pages')
+    """The remote branch to commit to when using gh-deploy."""
+
+    remote_name = config_options.Type(str, default='origin')
+    """The remote name to push to when using gh-deploy."""
+
+    extra = config_options.SubConfig()
+    """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."""
+
+    plugins = config_options.Plugins(default=['search'])
+    """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)."""
+
+    watch = config_options.ListOfPaths(default=[])
+    """A list of extra paths to watch while running `mkdocs serve`."""
diff --git a/mkdocs/contrib/search/__init__.py b/mkdocs/contrib/search/__init__.py
index 1523ac1e..5d8db7a4 100644
--- a/mkdocs/contrib/search/__init__.py
+++ b/mkdocs/contrib/search/__init__.py
@@ -1,10 +1,8 @@
-from __future__ import annotations
-
 import logging
 import os
 
 from mkdocs import utils
-from mkdocs.config import config_options
+from mkdocs.config import base, config_options
 from mkdocs.contrib.search.search_index import SearchIndex
 from mkdocs.plugins import BasePlugin
 
@@ -41,16 +39,18 @@ class LangOption(config_options.OptionallyRequired):
         return value
 
 
+class _PluginConfig:
+    lang = LangOption()
+    separator = config_options.Type(str, default=r'[\s\-]+')
+    min_search_length = config_options.Type(int, default=3)
+    prebuild_index = config_options.Choice((False, True, 'node', 'python'), default=False)
+    indexing = config_options.Choice(('full', 'sections', 'titles'), default='full')
+
+
 class SearchPlugin(BasePlugin):
     """Add a search feature to MkDocs."""
 
-    config_scheme = (
-        ('lang', LangOption()),
-        ('separator', config_options.Type(str, default=r'[\s\-]+')),
-        ('min_search_length', config_options.Type(int, default=3)),
-        ('prebuild_index', config_options.Choice((False, True, 'node', 'python'), default=False)),
-        ('indexing', config_options.Choice(('full', 'sections', 'titles'), default='full')),
-    )
+    config_scheme = base.get_schema(_PluginConfig)
 
     def on_config(self, config, **kwargs):
         "Add plugin templates and scripts to config."
@@ -63,7 +63,7 @@ class SearchPlugin(BasePlugin):
                 config['extra_javascript'].append('search/main.js')
         if self.config['lang'] is None:
             # lang setting undefined. Set default based on theme locale
-            validate = self.config_scheme[0][1].run_validation
+            validate = _PluginConfig.lang.run_validation
             self.config['lang'] = validate(config['theme']['locale'].language)
         # The `python` method of `prebuild_index` is pending deprecation as of version 1.2.
         # TODO: Raise a deprecation warning in a future release (1.3?).
diff --git a/mkdocs/contrib/search/search_index.py b/mkdocs/contrib/search/search_index.py
index 26c4741c..3e8c53ef 100644
--- a/mkdocs/contrib/search/search_index.py
+++ b/mkdocs/contrib/search/search_index.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import json
 import logging
 import os
@@ -149,9 +147,6 @@ class ContentSection:
         return self.text == other.text and self.id == other.id and self.title == other.title
 
 
-_HEADER_TAGS = tuple(f"h{x}" for x in range(1, 7))
-
-
 class ContentParser(HTMLParser):
     """
     Given a block of HTML, group the content under the preceding
@@ -172,7 +167,7 @@ class ContentParser(HTMLParser):
         """Called at the start of every HTML tag."""
 
         # We only care about the opening tag for headings.
-        if tag not in _HEADER_TAGS:
+        if tag not in ([f"h{x}" for x in range(1, 7)]):
             return
 
         # We are dealing with a new header, create a new section
@@ -189,7 +184,7 @@ class ContentParser(HTMLParser):
         """Called at the end of every HTML tag."""
 
         # We only care about the opening tag for headings.
-        if tag not in _HEADER_TAGS:
+        if tag not in ([f"h{x}" for x in range(1, 7)]):
             return
 
         self.is_header_tag = False
diff --git a/mkdocs/exceptions.py b/mkdocs/exceptions.py
index 281c190d..4ce39ba2 100644
--- a/mkdocs/exceptions.py
+++ b/mkdocs/exceptions.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 from click import ClickException, echo
 
 
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index 150282c6..92c6a1e2 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import functools
 import io
 import logging
diff --git a/mkdocs/localization.py b/mkdocs/localization.py
index 58658adc..30bda2b5 100644
--- a/mkdocs/localization.py
+++ b/mkdocs/localization.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import os
 
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 0c815fc9..cc8f5608 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -2,21 +2,16 @@
 Implements the plugin API for MkDocs.
 
 """
-from __future__ import annotations
+
 
 import logging
-import sys
 from collections import OrderedDict
-from typing import Any, Callable, Dict, Optional, Sequence, Tuple, TypeVar, overload
-
-if sys.version_info >= (3, 10):
-    from importlib.metadata import entry_points
-else:
-    from importlib_metadata import entry_points
+from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, overload
 
+import importlib_metadata
 import jinja2.environment
 
-from mkdocs.config.base import BaseConfigOption, Config
+from mkdocs.config.base import Config, PlainConfigSchema
 from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import Files
 from mkdocs.structure.nav import Navigation
@@ -28,7 +23,7 @@ log = logging.getLogger('mkdocs.plugins')
 def get_plugins():
     """Return a dict of all installed Plugins as {name: EntryPoint}."""
 
-    plugins = entry_points(group='mkdocs.plugins')
+    plugins = importlib_metadata.entry_points(group='mkdocs.plugins')
 
     # Allow third-party plugins to override core plugins
     pluginmap = {}
@@ -48,7 +43,7 @@ class BasePlugin:
     All plugins should subclass this class.
     """
 
-    config_scheme: Sequence[Tuple[str, BaseConfigOption]] = ()
+    config_scheme: PlainConfigSchema = ()
     config: Config = {}  # type: ignore[assignment]
 
     def load_config(
@@ -385,7 +380,7 @@ class PluginCollection(OrderedDict):
     def run_event(self, name: str, item: T, **kwargs) -> T:
         ...
 
-    def run_event(self, name, item=None, **kwargs) -> Optional[T]:
+    def run_event(self, name: str, item: T = None, **kwargs) -> Optional[T]:
         """
         Run all registered methods of an event.
 
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 795393d0..315f4c05 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import fnmatch
 import logging
 import os
@@ -22,11 +20,11 @@ log = logging.getLogger(__name__)
 class Files:
     """A collection of [File][mkdocs.structure.files.File] objects."""
 
-    def __init__(self, files: List[File]):
+    def __init__(self, files: List['File']):
         self._files = files
-        self._src_uris: Optional[Dict[str, File]] = None
+        self._src_uris: Optional[Dict[str, 'File']] = None
 
-    def __iter__(self) -> Iterator[File]:
+    def __iter__(self) -> Iterator['File']:
         """Iterate over the files within."""
         return iter(self._files)
 
@@ -39,28 +37,28 @@ class Files:
         return PurePath(path).as_posix() in self.src_uris
 
     @property
-    def src_paths(self) -> Dict[str, File]:
+    def src_paths(self) -> Dict[str, 'File']:
         """Soft-deprecated, prefer `src_uris`."""
         return {file.src_path: file for file in self._files}
 
     @property
-    def src_uris(self) -> Dict[str, File]:
+    def src_uris(self) -> Dict[str, 'File']:
         """A mapping containing every file, with the keys being their
         [`src_uri`][mkdocs.structure.files.File.src_uri]."""
         if self._src_uris is None:
             self._src_uris = {file.src_uri: file for file in self._files}
         return self._src_uris
 
-    def get_file_from_path(self, path: str) -> Optional[File]:
+    def get_file_from_path(self, path: str) -> Optional['File']:
         """Return a File instance with File.src_uri equal to path."""
         return self.src_uris.get(PurePath(path).as_posix())
 
-    def append(self, file: File) -> None:
+    def append(self, file: 'File') -> None:
         """Append file to Files collection."""
         self._src_uris = None
         self._files.append(file)
 
-    def remove(self, file: File) -> None:
+    def remove(self, file: 'File') -> None:
         """Remove file from Files collection."""
         self._src_uris = None
         self._files.remove(file)
@@ -71,23 +69,23 @@ class Files:
             if not file.is_documentation_page():
                 file.copy_file(dirty)
 
-    def documentation_pages(self) -> Iterable[File]:
+    def documentation_pages(self) -> Iterable['File']:
         """Return iterable of all Markdown page file objects."""
         return [file for file in self if file.is_documentation_page()]
 
-    def static_pages(self) -> Iterable[File]:
+    def static_pages(self) -> Iterable['File']:
         """Return iterable of all static page file objects."""
         return [file for file in self if file.is_static_page()]
 
-    def media_files(self) -> Iterable[File]:
+    def media_files(self) -> Iterable['File']:
         """Return iterable of all file objects which are not documentation or static pages."""
         return [file for file in self if file.is_media_file()]
 
-    def javascript_files(self) -> Iterable[File]:
+    def javascript_files(self) -> Iterable['File']:
         """Return iterable of all javascript file objects."""
         return [file for file in self if file.is_javascript()]
 
-    def css_files(self) -> Iterable[File]:
+    def css_files(self) -> Iterable['File']:
         """Return iterable of all CSS file objects."""
         return [file for file in self if file.is_css()]
 
@@ -170,7 +168,7 @@ class File:
     def dest_path(self, value):
         self.dest_uri = PurePath(value).as_posix()
 
-    page: Optional[Page]
+    page: Optional['Page']
 
     def __init__(self, path: str, src_dir: str, dest_dir: str, use_directory_urls: bool):
         self.page = None
@@ -225,7 +223,7 @@ class File:
                 url = dirname + '/'
         return urlquote(url)
 
-    def url_relative_to(self, other: File) -> str:
+    def url_relative_to(self, other: 'File') -> str:
         """Return url for file relative to other file."""
         return utils.get_relative_url(self.url, other.url if isinstance(other, File) else other)
 
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 7a78a74f..9be20b4d 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 from typing import Iterator, List, Optional, Union
 from urllib.parse import urlsplit
@@ -13,7 +11,7 @@ log = logging.getLogger(__name__)
 
 
 class Navigation:
-    def __init__(self, items: List[Union[Page, Section, Link]], pages: List[Page]) -> None:
+    def __init__(self, items: List[Union[Page, 'Section', 'Link']], pages: List[Page]) -> None:
         self.items = items  # Nested List with full navigation of Sections, Pages, and Links.
         self.pages = pages  # Flat List of subset of Pages in nav, in order.
 
@@ -30,9 +28,9 @@ class Navigation:
     """A flat list of all [page][mkdocs.structure.pages.Page] objects contained in the navigation. """
 
     def __repr__(self):
-        return '\n'.join(item._indent_print() for item in self)
+        return '\n'.join([item._indent_print() for item in self])
 
-    def __iter__(self) -> Iterator[Union[Page, Section, Link]]:
+    def __iter__(self) -> Iterator[Union[Page, 'Section', 'Link']]:
         return iter(self.items)
 
     def __len__(self) -> int:
@@ -40,7 +38,7 @@ class Navigation:
 
 
 class Section:
-    def __init__(self, title: str, children: List[Union[Page, Section, Link]]) -> None:
+    def __init__(self, title: str, children: List[Union[Page, 'Section', 'Link']]) -> None:
         self.title = title
         self.children = children
 
@@ -53,10 +51,10 @@ class Section:
     title: str
     """The title of the section."""
 
-    parent: Optional[Section]
+    parent: Optional['Section']
     """The immediate parent of the section or `None` if the section is at the top level."""
 
-    children: List[Union[Page, Section, Link]]
+    children: List[Union[Page, 'Section', 'Link']]
     """An iterable of all child navigation objects. Children may include nested sections, pages and links."""
 
     @property
@@ -114,7 +112,7 @@ class Link:
     """The URL that the link points to. The URL should always be an absolute URLs and
     should not need to have `base_url` prepended."""
 
-    parent: Optional[Section]
+    parent: Optional['Section']
     """The immediate parent of the link. `None` if the link is at the top level."""
 
     children: None = None
@@ -161,7 +159,7 @@ def get_navigation(files: Files, config: Config) -> Navigation:
         log.info(
             'The following pages exist in the docs directory, but are not '
             'included in the "nav" configuration:\n  - {}'.format(
-                '\n  - '.join(file.src_path for file in missing_from_config)
+                '\n  - '.join([file.src_path for file in missing_from_config])
             )
         )
         # Any documentation files not found in the nav should still have an associated page, so we
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 20616af7..f65b355e 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import os
 import posixpath
@@ -124,12 +122,12 @@ class Page:
         """Evaluates to `True` for the homepage of the site and `False` for all other pages."""
         return self.is_top_level and self.is_index and self.file.url in ['.', 'index.html']
 
-    previous_page: Optional[Page]
+    previous_page: Optional['Page']
     """The [page][mkdocs.structure.pages.Page] object for the previous page or `None`.
     The value will be `None` if the current page is the first item in the site navigation
     or if the current page is not included in the navigation at all."""
 
-    next_page: Optional[Page]
+    next_page: Optional['Page']
     """The [page][mkdocs.structure.pages.Page] object for the next page or `None`.
     The value will be `None` if the current page is the last item in the site navigation
     or if the current page is not included in the navigation at all."""
diff --git a/mkdocs/structure/toc.py b/mkdocs/structure/toc.py
index 8d4e7f95..492e5712 100644
--- a/mkdocs/structure/toc.py
+++ b/mkdocs/structure/toc.py
@@ -5,8 +5,6 @@ For the sake of simplicity we use the Python-Markdown `toc` extension to
 generate a list of dicts for each toc item, and then store it as AnchorLinks to
 maintain compatibility with older versions of MkDocs.
 """
-from __future__ import annotations
-
 from typing import List
 
 
@@ -33,7 +31,7 @@ class TableOfContents:
         return len(self.items)
 
     def __str__(self):
-        return ''.join(str(item) for item in self)
+        return ''.join([str(item) for item in self])
 
 
 class AnchorLink:
@@ -56,7 +54,7 @@ class AnchorLink:
     level: int
     """The zero-based level of the item."""
 
-    children: List[AnchorLink]
+    children: List['AnchorLink']
     """An iterable of any child items."""
 
     def __str__(self):
diff --git a/mkdocs/theme.py b/mkdocs/theme.py
index d27adb40..fb7aada4 100644
--- a/mkdocs/theme.py
+++ b/mkdocs/theme.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 import logging
 import os
 
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index c365f877..a325bd31 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -4,7 +4,7 @@ Standalone file utils.
 Nothing in this module should have an knowledge of config or the layout
 and structure of the site and pages in the site.
 """
-from __future__ import annotations
+
 
 import functools
 import logging
@@ -12,18 +12,13 @@ import os
 import posixpath
 import re
 import shutil
-import sys
 import warnings
 from collections import defaultdict
 from datetime import datetime, timezone
 from pathlib import PurePath
 from urllib.parse import urlsplit
 
-if sys.version_info >= (3, 10):
-    from importlib.metadata import entry_points
-else:
-    from importlib_metadata import entry_points
-
+import importlib_metadata
 import yaml
 from mergedeep import merge
 from yaml_env_tag import construct_env_tag
@@ -312,7 +307,7 @@ def get_themes():
     """Return a dict of all installed themes as {name: EntryPoint}."""
 
     themes = {}
-    eps = set(entry_points(group='mkdocs.themes'))
+    eps = set(importlib_metadata.entry_points(group='mkdocs.themes'))
     builtins = {ep.name for ep in eps if ep.dist.name == 'mkdocs'}
 
     for theme in eps:
diff --git a/mkdocs/utils/babel_stub.py b/mkdocs/utils/babel_stub.py
index 59b1d269..aa64eedb 100644
--- a/mkdocs/utils/babel_stub.py
+++ b/mkdocs/utils/babel_stub.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 from string import ascii_letters
 from typing import NamedTuple
 
diff --git a/mkdocs/utils/filters.py b/mkdocs/utils/filters.py
index 26daa9f1..d8f4a1d2 100644
--- a/mkdocs/utils/filters.py
+++ b/mkdocs/utils/filters.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
 try:
     from jinja2 import pass_context as contextfilter
 except ImportError:
diff --git a/mkdocs/utils/meta.py b/mkdocs/utils/meta.py
index 3e27c382..eeaee4e6 100644
--- a/mkdocs/utils/meta.py
+++ b/mkdocs/utils/meta.py
@@ -32,7 +32,7 @@ MultiMarkdown Meta-Data
 Extracts, parses and transforms MultiMarkdown style data from documents.
 
 """
-from __future__ import annotations
+
 
 import re
 
diff --git a/requirements/project-min.txt b/requirements/project-min.txt
index ce88eeae..c40e3c9e 100644
--- a/requirements/project-min.txt
+++ b/requirements/project-min.txt
@@ -6,7 +6,7 @@ PyYAML==5.1
 watchdog==2.0.0
 ghp-import==1.0
 pyyaml_env_tag==0.1
-importlib_metadata==4.3; python_version < "3.10"
+importlib_metadata==4.3
 packaging==20.5
 mergedeep==1.3.4
 babel==2.9.0
@@ -16,4 +16,4 @@ mdx_gh_links==0.2
 markdown-callouts==0.2
 mkdocs-redirects==1.0.1
 pymdown-extensions==8.0.1
-mkdocstrings-python==0.7.1
+mkdocstrings-python==0.7.1; python_version >= '3.7'
diff --git a/requirements/project.txt b/requirements/project.txt
index b952b7ba..781b7503 100644
--- a/requirements/project.txt
+++ b/requirements/project.txt
@@ -6,7 +6,7 @@ PyYAML>=5.1
 watchdog>=2.0.0
 ghp-import>=1.0
 pyyaml_env_tag>=0.1
-importlib_metadata>=4.3; python_version < "3.10"
+importlib_metadata>=4.3
 packaging>=20.5
 mergedeep>=1.3.4
 babel>=2.9.0
@@ -16,4 +16,4 @@ mdx_gh_links>=0.2
 markdown-callouts>=0.2
 mkdocs-redirects>=1.0.1
 pymdown-extensions>=8.0.1
-mkdocstrings-python>=0.7.1
+mkdocstrings-python>=0.7.1; python_version >= '3.7'
diff --git a/setup.cfg b/setup.cfg
index b37f0413..52863bd1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,7 +13,6 @@ line_length = 100
 ignore_missing_imports = true
 warn_unused_ignores = true
 show_error_codes = true
-no_implicit_optional = true
 
 [compile_catalog]
 statistics = True
diff --git a/setup.py b/setup.py
index 905d3518..cd518f16 100755
--- a/setup.py
+++ b/setup.py
@@ -70,12 +70,12 @@ setup(
         'watchdog>=2.0',
         'ghp-import>=1.0',
         'pyyaml_env_tag>=0.1',
-        'importlib_metadata>=4.3; python_version < "3.10"',
+        'importlib_metadata>=4.3',
         'packaging>=20.5',
         'mergedeep>=1.3.4'
     ],
     extras_require={"i18n": ['babel>=2.9.0']},
-    python_requires='>=3.7',
+    python_requires='>=3.6',
     entry_points={
         'console_scripts': [
             'mkdocs = mkdocs.__main__:cli',
diff --git a/tox.ini b/tox.ini
index 4d7e3b1d..c607b793 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,6 @@
 [tox]
 envlist =
+    py36-{unittests,min-req},
     py{37,38,39,310,py3}-{unittests,min-req,integration,integration-no-babel},
     black, isort, flake8, markdown-lint, linkchecker, jshint, csslint, nobabel, codespell
 
@@ -8,18 +9,18 @@ passenv = LANG
 deps=
     py{37,38,39,310,py3}-integration: -rrequirements/project.txt
     py{37,38,39,310,py3}-min-req: -rrequirements/project-min.txt
-    py{37,38,39,310,py3}-{unittests,min-req}: -rrequirements/test.txt
+    py{36,37,38,39,310,py3}-{unittests,min-req}: -rrequirements/test.txt
 commands=
     {envpython} --version
-    py{37,38,39,310,py3}-{unittests,min-req}:  {envbindir}/coverage run --source=mkdocs --omit 'mkdocs/tests/*' -m unittest discover -p '*tests.py' mkdocs --top-level-directory .
-    py{37,38,39,310,py3}-unittests: {envbindir}/coverage xml
-    py{37,38,39,310,py3}-unittests: {envbindir}/coverage report --show-missing
+    py{36,37,38,39,310,py3}-{unittests,min-req}:  {envbindir}/coverage run --source=mkdocs --omit 'mkdocs/tests/*' -m unittest discover -p '*tests.py' mkdocs --top-level-directory .
+    py{36,37,38,39,310,py3}-unittests: {envbindir}/coverage xml
+    py{36,37,38,39,310,py3}-unittests: {envbindir}/coverage report --show-missing
     py{37,38,39,310,py3}-integration: {envpython} -m mkdocs.tests.integration --output={envtmpdir}/builds
 
 [testenv:black]
 deps=black
 skip_install=true
-commands={envbindir}/black -l100 -tpy37 --skip-string-normalization mkdocs
+commands={envbindir}/black -l100 -tpy36 --skip-string-normalization mkdocs
 
 [testenv:isort]
 deps=isort


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 3b17ed7d..0bcf7fb8 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1,3 +1,4 @@
+import contextlib
 import os
 import sys
 import textwrap
@@ -7,11 +8,32 @@ from unittest.mock import patch
 import mkdocs
 from mkdocs.config import config_options
 from mkdocs.config.base import Config
-from mkdocs.exceptions import ConfigurationError
 from mkdocs.tests.base import tempdir
 from mkdocs.utils import yaml_load
 
 
+class UnexpectedError(Exception):
+    pass
+
+
+class TestCase(unittest.TestCase):
+    @contextlib.contextmanager
+    def expect_error(self, **kwargs):
+        [(key, msg)] = kwargs.items()
+        with self.assertRaises(UnexpectedError) as cm:
+            yield
+        self.assertEqual(f'{key}="{msg}"', str(cm.exception))
+
+    def get_config(self, schema, cfg, warnings={}):
+        config = Config(schema)
+        config.load_dict(cfg)
+        actual_errors, actual_warnings = config.validate()
+        if actual_errors:
+            raise UnexpectedError(', '.join(f'{key}="{msg}"' for key, msg in actual_errors))
+        self.assertEqual(warnings, dict(actual_warnings))
+        return config
+
+
 class OptionallyRequiredTest(unittest.TestCase):
     def test_empty(self):
 
@@ -19,7 +41,7 @@ class OptionallyRequiredTest(unittest.TestCase):
         value = option.validate(None)
         self.assertEqual(value, None)
 
-        self.assertEqual(option.is_required(), False)
+        self.assertEqual(option.required, False)
 
     def test_required(self):
 
@@ -27,7 +49,7 @@ class OptionallyRequiredTest(unittest.TestCase):
         with self.assertRaises(config_options.ValidationError):
             option.validate(None)
 
-        self.assertEqual(option.is_required(), True)
+        self.assertEqual(option.required, True)
 
     def test_required_no_default(self):
 
@@ -83,6 +105,15 @@ class ChoiceTest(unittest.TestCase):
         value = option.validate('python')
         self.assertEqual(value, 'python')
 
+    def test_default(self):
+        option = config_options.Choice(('python', 'node'), default='node')
+        value = option.validate(None)
+        self.assertEqual(value, 'node')
+
+    def test_excluded_default(self):
+        with self.assertRaises(ValueError):
+            config_options.Choice(('python', 'node'), default='a')
+
     def test_invalid_choice(self):
         option = config_options.Choice(('python', 'node'))
         with self.assertRaises(config_options.ValidationError):
@@ -350,61 +381,138 @@ class RepoURLTest(unittest.TestCase):
         self.assertEqual(config.get('edit_uri'), 'edit/master/docs/')
 
 
-class DirTest(unittest.TestCase):
-    def test_valid_dir(self):
+class ListOfItemsTest(TestCase):
+    def test_int_type(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.Type(int))),
+        ]
+        cfg = self.get_config(schema, {'option': [1, 2, 3]})
+        self.assertEqual(cfg['option'], [1, 2, 3])
 
-        d = os.path.dirname(__file__)
-        option = config_options.Dir(exists=True)
-        value = option.validate(d)
-        self.assertEqual(d, value)
+        with self.expect_error(
+            option="Expected type: <class 'int'> but received: <class 'NoneType'>"
+        ):
+            cfg = self.get_config(schema, {'option': [1, None, 3]})
 
-    def test_missing_dir(self):
+    def test_combined_float_type(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.Type((int, float)))),
+        ]
+        cfg = self.get_config(schema, {'option': [1.4, 2, 3]})
+        self.assertEqual(cfg['option'], [1.4, 2, 3])
 
-        d = os.path.join("not", "a", "real", "path", "I", "hope")
-        option = config_options.Dir()
-        value = option.validate(d)
-        self.assertEqual(os.path.abspath(d), value)
+        with self.expect_error(
+            option="Expected type: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
+        ):
+            self.get_config(schema, {'option': ['a']})
 
-    def test_missing_dir_but_required(self):
+    def test_list_default(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.Type(int))),
+        ]
+        cfg = self.get_config(schema, {})
+        self.assertEqual(cfg['option'], [])
 
-        d = os.path.join("not", "a", "real", "path", "I", "hope")
-        option = config_options.Dir(exists=True)
-        with self.assertRaises(config_options.ValidationError):
-            option.validate(d)
+        cfg = self.get_config(schema, {'option': None})
+        self.assertEqual(cfg['option'], [])
 
-    def test_file(self):
+    def test_none_default(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.Type(str), default=None)),
+        ]
+        cfg = self.get_config(schema, {})
+        self.assertEqual(cfg['option'], None)
+
+        cfg = self.get_config(schema, {'option': None})
+        self.assertEqual(cfg['option'], None)
+
+        cfg = self.get_config(schema, {'option': ['foo']})
+        self.assertEqual(cfg['option'], ['foo'])
+
+    def test_string_not_a_list_of_strings(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.Type(str))),
+        ]
+        with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
+            self.get_config(schema, {'option': 'foo'})
+
+    def test_post_validation_error(self):
+        schema = [
+            ('option', config_options.ListOfItems(config_options.IpAddress())),
+        ]
+        with self.expect_error(option="'asdf' is not a valid port"):
+            self.get_config(schema, {'option': ["localhost:8000", "1.2.3.4:asdf"]})
+
+
+class FilesystemObjectTest(unittest.TestCase):
+    def test_valid_dir(self):
+        for cls in config_options.Dir, config_options.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.dirname(__file__)
+                option = cls(exists=True)
+                value = option.validate(d)
+                self.assertEqual(d, value)
+
+    def test_valid_file(self):
+        for cls in config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                f = __file__
+                option = cls(exists=True)
+                value = option.validate(f)
+                self.assertEqual(f, value)
+
+    def test_missing_without_exists(self):
+        for cls in config_options.Dir, config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.join("not", "a", "real", "path", "I", "hope")
+                option = cls()
+                value = option.validate(d)
+                self.assertEqual(os.path.abspath(d), value)
+
+    def test_missing_but_required(self):
+        for cls in config_options.Dir, config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                d = os.path.join("not", "a", "real", "path", "I", "hope")
+                option = cls(exists=True)
+                with self.assertRaises(config_options.ValidationError):
+                    option.validate(d)
+
+    def test_not_a_dir(self):
         d = __file__
         option = config_options.Dir(exists=True)
         with self.assertRaises(config_options.ValidationError):
             option.validate(d)
 
-    def test_incorrect_type_attribute_error(self):
-        option = config_options.Dir()
-        with self.assertRaises(config_options.ValidationError):
-            option.validate(1)
-
-    def test_incorrect_type_type_error(self):
-        option = config_options.Dir()
+    def test_not_a_file(self):
+        d = os.path.dirname(__file__)
+        option = config_options.File(exists=True)
         with self.assertRaises(config_options.ValidationError):
-            option.validate([])
+            option.validate(d)
 
-    def test_dir_unicode(self):
-        cfg = Config(
-            [('dir', config_options.Dir())],
-            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
-        )
+    def test_incorrect_type_error(self):
+        for cls in config_options.Dir, config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                option = cls()
+                with self.assertRaises(config_options.ValidationError):
+                    option.validate(1)
+                with self.assertRaises(config_options.ValidationError):
+                    option.validate([])
 
-        test_config = {
-            'dir': 'юникод',
-        }
+    def test_with_unicode(self):
+        for cls in config_options.Dir, config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                cfg = Config(
+                    [('dir', cls())],
+                    config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
+                )
 
-        cfg.load_dict(test_config)
+                cfg.load_dict({'dir': 'юникод'})
 
-        fails, warns = cfg.validate()
+                fails, warns = cfg.validate()
 
-        self.assertEqual(len(fails), 0)
-        self.assertEqual(len(warns), 0)
-        self.assertIsInstance(cfg['dir'], str)
+                self.assertEqual(len(fails), 0)
+                self.assertEqual(len(warns), 0)
+                self.assertIsInstance(cfg['dir'], str)
 
     def test_dir_filesystemencoding(self):
         cfg = Config(
@@ -442,28 +550,30 @@ class DirTest(unittest.TestCase):
         self.assertEqual(len(warns), 0)
 
     def test_config_dir_prepended(self):
-        base_path = os.path.abspath('.')
-        cfg = Config(
-            [('dir', config_options.Dir())],
-            config_file_path=os.path.join(base_path, 'mkdocs.yml'),
-        )
+        for cls in config_options.Dir, config_options.File, config_options.FilesystemObject:
+            with self.subTest(cls):
+                base_path = os.path.abspath('.')
+                cfg = Config(
+                    [('dir', cls())],
+                    config_file_path=os.path.join(base_path, 'mkdocs.yml'),
+                )
 
-        test_config = {
-            'dir': 'foo',
-        }
+                test_config = {
+                    'dir': 'foo',
+                }
 
-        cfg.load_dict(test_config)
+                cfg.load_dict(test_config)
 
-        fails, warns = cfg.validate()
+                fails, warns = cfg.validate()
 
-        self.assertEqual(len(fails), 0)
-        self.assertEqual(len(warns), 0)
-        self.assertIsInstance(cfg['dir'], str)
-        self.assertEqual(cfg['dir'], os.path.join(base_path, 'foo'))
+                self.assertEqual(len(fails), 0)
+                self.assertEqual(len(warns), 0)
+                self.assertIsInstance(cfg['dir'], str)
+                self.assertEqual(cfg['dir'], os.path.join(base_path, 'foo'))
 
-    def test_dir_is_config_dir_fails(self):
+    def test_site_dir_is_config_dir_fails(self):
         cfg = Config(
-            [('dir', config_options.Dir())],
+            [('dir', config_options.DocsDir())],
             config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
         )
 
@@ -491,6 +601,12 @@ class ListOfPathsTest(unittest.TestCase):
         with self.assertRaises(config_options.ValidationError):
             option.validate(paths)
 
+    def test_non_path(self):
+        paths = [os.path.dirname(__file__), None]
+        option = config_options.ListOfPaths()
+        with self.assertRaises(config_options.ValidationError):
+            option.validate(paths)
+
     def test_empty_list(self):
         paths = []
         option = config_options.ListOfPaths()
@@ -859,11 +975,11 @@ class SubConfigTest(unittest.TestCase):
     def test_subconfig_wrong_type(self):
         # Test that an error is raised if subconfig does not receive a dict
         option = config_options.SubConfig()
-        with self.assertRaises(ConfigurationError):
+        with self.assertRaises(config_options.ValidationError):
             option.validate("not_a_dict")
-        with self.assertRaises(ConfigurationError):
+        with self.assertRaises(config_options.ValidationError):
             option.validate(("not_a_dict",))
-        with self.assertRaises(ConfigurationError):
+        with self.assertRaises(config_options.ValidationError):
             option.validate(["not_a_dict"])
 
     def test_subconfig_default(self):
@@ -915,6 +1031,74 @@ class SubConfigTest(unittest.TestCase):
         assert res == dict(c='foo')
 
 
+class ConfigItemsTest(TestCase):
+    def test_non_required(self):
+        schema = {
+            'sub': config_options.ConfigItems(
+                ('opt', config_options.Type(int)),
+                validate=True,
+            ),
+        }.items()
+
+        cfg = self.get_config(schema, {})
+        self.assertEqual(cfg['sub'], [])
+
+        cfg = self.get_config(schema, {'sub': None})
+        self.assertEqual(cfg['sub'], [])
+
+        cfg = self.get_config(schema, {'sub': [{'opt': 1}, {}]})
+        self.assertEqual(cfg['sub'], [{'opt': 1}, {'opt': None}])
+
+    def test_required(self):
+        schema = {
+            'sub': config_options.ConfigItems(
+                ('opt', config_options.Type(str, required=True)),
+                validate=True,
+            ),
+        }.items()
+
+        cfg = self.get_config(schema, {})
+        self.assertEqual(cfg['sub'], [])
+
+        cfg = self.get_config(schema, {'sub': None})
+        self.assertEqual(cfg['sub'], [])
+
+        with self.expect_error(
+            sub="Sub-option 'opt' configuration error: Expected type: <class 'str'> but received: <class 'int'>"
+        ):
+            cfg = self.get_config(schema, {'sub': [{'opt': 1}, {}]})
+
+    def test_common(self):
+        for required in False, True:
+            with self.subTest(required=required):
+                schema = {
+                    'sub': config_options.ConfigItems(
+                        ('opt', config_options.Type(int, required=required)),
+                        validate=True,
+                    ),
+                }.items()
+
+                cfg = self.get_config(schema, {'sub': None})
+                self.assertEqual(cfg['sub'], [])
+
+                cfg = self.get_config(schema, {'sub': []})
+
+                cfg = self.get_config(schema, {'sub': [{'opt': 1}, {'opt': 2}]})
+                self.assertEqual(cfg['sub'], [{'opt': 1}, {'opt': 2}])
+
+                with self.expect_error(
+                    sub="Sub-option 'opt' configuration error: "
+                    "Expected type: <class 'int'> but received: <class 'str'>"
+                ):
+                    cfg = self.get_config(schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})
+
+                with self.expect_error(
+                    sub="The configuration is invalid. The expected type was a key value mapping "
+                    "(a python dict) but we got an object of type: <class 'int'>"
+                ):
+                    cfg = self.get_config(schema, {'sub': [1, 2]})
+
+
 class MarkdownExtensionsTest(unittest.TestCase):
     @patch('markdown.Markdown')
     def test_simple_list(self, mockMd):
@@ -1067,6 +1251,19 @@ class MarkdownExtensionsTest(unittest.TestCase):
             config,
         )
 
+    def test_missing_default(self):
+        option = config_options.MarkdownExtensions()
+        config = {}
+        config['markdown_extensions'] = option.validate(None)
+        option.post_validation(config, 'markdown_extensions')
+        self.assertEqual(
+            {
+                'markdown_extensions': [],
+                'mdx_configs': {},
+            },
+            config,
+        )
+
     def test_none(self):
         option = config_options.MarkdownExtensions(default=[])
         config = {
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index 6a9a82cd..ba22a3c4 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -12,6 +12,8 @@ from mkdocs.exceptions import ConfigurationError
 from mkdocs.localization import parse_locale
 from mkdocs.tests.base import dedent
 
+DEFAULT_SCHEMA = defaults.get_schema()
+
 
 class ConfigTests(unittest.TestCase):
     def test_missing_config_file(self):
@@ -19,7 +21,7 @@ class ConfigTests(unittest.TestCase):
             config.load_config(config_file='bad_filename.yaml')
 
     def test_missing_site_name(self):
-        c = config.Config(schema=defaults.get_schema())
+        c = config.Config(schema=DEFAULT_SCHEMA)
         c.load_dict({})
         errors, warnings = c.validate()
         self.assertEqual(len(errors), 1)
@@ -219,7 +221,7 @@ class ConfigTests(unittest.TestCase):
                     self.assertEqual({k: c['theme'][k] for k in iter(c['theme'])}, result['vars'])
 
     def test_empty_nav(self):
-        conf = config.Config(schema=defaults.get_schema())
+        conf = config.Config(schema=DEFAULT_SCHEMA)
         conf.load_dict(
             {
                 'site_name': 'Example',
@@ -230,7 +232,7 @@ class ConfigTests(unittest.TestCase):
         self.assertEqual(conf['nav'], None)
 
     def test_error_on_pages(self):
-        conf = config.Config(schema=defaults.get_schema())
+        conf = config.Config(schema=DEFAULT_SCHEMA)
         conf.load_dict(
             {
                 'site_name': 'Example',
@@ -305,9 +307,8 @@ class ConfigTests(unittest.TestCase):
     def test_multiple_markdown_config_instances(self):
         # This had a bug where an extension config would persist to separate
         # config instances that didn't specify extensions.
-        schema = config.defaults.get_schema()
 
-        conf = config.Config(schema=schema)
+        conf = config.Config(schema=DEFAULT_SCHEMA)
         conf.load_dict(
             {
                 'site_name': 'Example',
@@ -317,7 +318,7 @@ class ConfigTests(unittest.TestCase):
         conf.validate()
         self.assertEqual(conf['mdx_configs'].get('toc'), {'permalink': '##'})
 
-        conf = config.Config(schema=schema)
+        conf = config.Config(schema=DEFAULT_SCHEMA)
         conf.load_dict(
             {'site_name': 'Example'},
         )
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 631cd30d..cbd42380 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -7,17 +7,19 @@ from unittest import mock
 
 from mkdocs import config, plugins
 from mkdocs.commands import build
-from mkdocs.config import config_options
+from mkdocs.config import base, config_options
 from mkdocs.exceptions import Abort, BuildError, PluginError
 from mkdocs.tests.base import load_config
 
 
+class _DummyPluginConfig:
+    foo = config_options.Type(str, default='default foo')
+    bar = config_options.Type(int, default=0)
+    dir = config_options.Dir(exists=False)
+
+
 class DummyPlugin(plugins.BasePlugin):
-    config_scheme = (
-        ('foo', config_options.Type(str, default='default foo')),
-        ('bar', config_options.Type(int, default=0)),
-        ('dir', config_options.Dir(exists=False)),
-    )
+    config_scheme = base.get_schema(_DummyPluginConfig)
 
     def on_pre_page(self, content, **kwargs):
         """modify page content by prepending `foo` config value."""
@@ -239,7 +241,7 @@ MockEntryPoint = mock.Mock()
 MockEntryPoint.configure_mock(**{'name': 'sample', 'load.return_value': DummyPlugin})
 
 
-@mock.patch('mkdocs.plugins.entry_points', return_value=[MockEntryPoint])
+@mock.patch('importlib_metadata.entry_points', return_value=[MockEntryPoint])
 class TestPluginConfig(unittest.TestCase):
     def test_plugin_config_without_options(self, mock_class):
 
diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py
index 7f1f8f83..a59ce5de 100644
--- a/mkdocs/tests/utils/utils_tests.py
+++ b/mkdocs/tests/utils/utils_tests.py
@@ -293,7 +293,7 @@ class UtilsTests(unittest.TestCase):
 
         self.assertEqual(sorted(utils.get_theme_names()), ['mkdocs', 'readthedocs'])
 
-    @mock.patch('mkdocs.utils.entry_points', autospec=True)
+    @mock.patch('importlib_metadata.entry_points', autospec=True)
     def test_get_theme_dir(self, mock_iter):
 
         path = 'some/path'
@@ -312,7 +312,7 @@ class UtilsTests(unittest.TestCase):
         with self.assertRaises(KeyError):
             utils.get_theme_dir('nonexistanttheme')
 
-    @mock.patch('mkdocs.utils.entry_points', autospec=True)
+    @mock.patch('importlib_metadata.entry_points', autospec=True)
     def test_get_theme_dir_importerror(self, mock_iter):
 
         theme = mock.Mock()
@@ -325,7 +325,7 @@ class UtilsTests(unittest.TestCase):
         with self.assertRaises(ImportError):
             utils.get_theme_dir(theme.name)
 
-    @mock.patch('mkdocs.utils.entry_points', autospec=True)
+    @mock.patch('importlib_metadata.entry_points', autospec=True)
     def test_get_themes_warning(self, mock_iter):
 
         theme1 = mock.Mock()
@@ -342,7 +342,7 @@ class UtilsTests(unittest.TestCase):
 
         self.assertEqual(sorted(utils.get_theme_names()), sorted(['mkdocs2']))
 
-    @mock.patch('mkdocs.utils.entry_points', autospec=True)
+    @mock.patch('importlib_metadata.entry_points', autospec=True)
     def test_get_themes_error(self, mock_iter):
 
         theme1 = mock.Mock()