Repository: mkdocs/mkdocs
Pull request #3445 (2023-10-28 16:30:41)

Stop suppressing OSError indiscriminately during build
This was aimed at user-facing "file not found" errors from "serve" but it can catch errors from plugins as well, and it only makes the errors very confusing.



Patch:
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 9c6eff4e..8353b0cf 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -333,7 +333,9 @@ exclude_docs: |
 
 NEW: **New in version 1.5.**
 
-NOTE: This option does *not* actually exclude anything from the nav.
+> NEW: **New in version 1.6:**
+>
+> If the [`nav`](#nav) config is not specified at all, pages specified in this config will now be excluded from the inferred navigation.
 
 If you want to include some docs into the site but intentionally exclude them from the nav, normally MkDocs warns about this.
 
@@ -798,6 +800,8 @@ You might have seen this feature in the [mkdocs-simple-hooks plugin](https://git
 A list of plugins (with optional configuration settings) to use when building
 the site. See the [Plugins] documentation for full details.
 
+**default**: `['search']` (the "search" plugin included with MkDocs).
+
 If the `plugins` config setting is defined in the `mkdocs.yml` config file, then
 any defaults (such as `search`) are ignored and you need to explicitly re-enable
 the defaults if you would like to continue using them:
@@ -818,6 +822,30 @@ plugins:
       option2: other value
 ```
 
+To completely disable all plugins, including any defaults, set the `plugins`
+setting to an empty list:
+
+```yaml
+plugins: []
+```
+
+#### `enabled` option
+
+> NEW: **New in MkDocs 1.6.**
+>
+> Each plugin has its own options keys. However MkDocs also ensures that each plugin has the `enabled` boolean option. This can be used to conditionally enable a particular plugin, as in the following example:
+>
+> ```yaml
+> plugins:
+>   - search
+>   - code-validator:
+>       enabled: !ENV [LINT, false]
+> ```
+>
+> See: [Environment variables](#environment-variables)
+
+#### Alternate syntax
+
 In the above examples, each plugin is a list item (starts with a `-`). As an
 alternative, key/value pairs can be used instead. However, in that case an empty
 value must be provided for plugins for which no options are defined. Therefore,
@@ -834,15 +862,6 @@ plugins:
 This alternative syntax is required if you intend to override some options via
 [inheritance].
 
-To completely disable all plugins, including any defaults, set the `plugins`
-setting to an empty list:
-
-```yaml
-plugins: []
-```
-
-**default**: `['search']` (the "search" plugin included with MkDocs).
-
 #### Search
 
 A search plugin is provided by default with MkDocs which uses [lunr.js] as a
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index e8af54fb..4a21a3da 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -13,7 +13,7 @@ from jinja2.exceptions import TemplateNotFound
 import mkdocs
 from mkdocs import utils
 from mkdocs.exceptions import Abort, BuildError
-from mkdocs.structure.files import File, Files, InclusionLevel, _set_exclusions, get_files
+from mkdocs.structure.files import File, Files, InclusionLevel, get_files, set_exclusions
 from mkdocs.structure.nav import Navigation, get_navigation
 from mkdocs.structure.pages import Page
 from mkdocs.utils import DuplicateFilter  # noqa: F401 - legacy re-export
@@ -294,7 +294,7 @@ def build(
         # Run `files` plugin events.
         files = config.plugins.on_files(files, config=config)
         # If plugins have added files but haven't set their inclusion level, calculate it again.
-        _set_exclusions(files._files, config)
+        set_exclusions(files._files, config)
 
         nav = get_navigation(files, config)
 
@@ -305,8 +305,8 @@ def build(
         excluded = []
         for file in files.documentation_pages(inclusion=inclusion):
             log.debug(f"Reading: {file.src_uri}")
-            if file.page is None and file.inclusion.is_excluded():
-                if live_server:
+            if file.page is None and file.inclusion.is_not_in_nav():
+                if live_server and file.inclusion.is_excluded():
                     excluded.append(urljoin(live_server.url, file.url))
                 Page(None, file, config)
             assert file.page is not None
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index f6e5fa05..50c3fa07 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -6,7 +6,7 @@ import re
 import subprocess
 from typing import TYPE_CHECKING
 
-import ghp_import
+import ghp_import  # type: ignore
 from packaging import version
 
 import mkdocs
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 0062b91c..dc472a66 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -7,11 +7,8 @@ from os.path import isdir, isfile, join
 from typing import TYPE_CHECKING
 from urllib.parse import urlsplit
 
-import jinja2.exceptions
-
 from mkdocs.commands.build import build
 from mkdocs.config import load_config
-from mkdocs.exceptions import Abort
 from mkdocs.livereload import LiveReloadServer
 
 if TYPE_CHECKING:
@@ -107,12 +104,6 @@ def serve(
             log.info("Shutting down...")
         finally:
             server.shutdown()
-    except jinja2.exceptions.TemplateError:
-        # This is a subclass of OSError, but shouldn't be suppressed.
-        raise
-    except OSError as e:  # pragma: no cover
-        # Avoid ugly, unhelpful traceback
-        raise Abort(f'{type(e).__name__}: {e}')
     finally:
         config.plugins.on_shutdown()
         if isdir(site_dir):
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 434dfd6a..aacff403 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -45,6 +45,8 @@ from mkdocs.exceptions import ConfigurationError
 T = TypeVar('T')
 SomeConfig = TypeVar('SomeConfig', bound=Config)
 
+log = logging.getLogger(__name__)
+
 
 class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]):
     """
@@ -1137,6 +1139,17 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
                 "because the plugin doesn't declare `supports_multiple_instances`."
             )
 
+        # Only if the plugin doesn't have its own "enabled" config, apply a generic one.
+        if 'enabled' in config and not any(pair[0] == 'enabled' for pair in plugin.config_scheme):
+            enabled = config.pop('enabled')
+            if not isinstance(enabled, bool):
+                raise ValidationError(
+                    f"Plugin '{name}' option 'enabled': Expected boolean but received: {type(enabled)}"
+                )
+            if not enabled:
+                log.debug(f"Plugin '{inst_name}' is disabled in the config, skipping.")
+                return plugin
+
         errors, warns = plugin.load_config(
             config, self._config.config_file_path if self._config else None
         )
diff --git a/mkdocs/contrib/search/__init__.py b/mkdocs/contrib/search/__init__.py
index 77b55e22..74b96bbb 100644
--- a/mkdocs/contrib/search/__init__.py
+++ b/mkdocs/contrib/search/__init__.py
@@ -13,7 +13,8 @@ from mkdocs.plugins import BasePlugin
 if TYPE_CHECKING:
     from mkdocs.config.defaults import MkDocsConfig
     from mkdocs.structure.pages import Page
-    from mkdocs.util.templates import TemplateContext
+    from mkdocs.utils.templates import TemplateContext
+
 
 log = logging.getLogger(__name__)
 base_path = os.path.dirname(os.path.abspath(__file__))
diff --git a/mkdocs/contrib/search/search_index.py b/mkdocs/contrib/search/search_index.py
index d48130ec..770f24fe 100644
--- a/mkdocs/contrib/search/search_index.py
+++ b/mkdocs/contrib/search/search_index.py
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
     from mkdocs.structure.toc import AnchorLink, TableOfContents
 
 try:
-    from lunr import lunr
+    from lunr import lunr  # type: ignore
 
     haslunrpy = True
 except ImportError:
diff --git a/mkdocs/localization.py b/mkdocs/localization.py
index 87a27560..b9c70790 100644
--- a/mkdocs/localization.py
+++ b/mkdocs/localization.py
@@ -17,7 +17,7 @@ try:
 
     has_babel = True
 except ImportError:  # pragma: no cover
-    from mkdocs.utils.babel_stub import Locale, UnknownLocaleError
+    from mkdocs.utils.babel_stub import Locale, UnknownLocaleError  # type: ignore
 
     has_babel = False
 
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index a96ef3be..fe55b4b7 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -454,12 +454,20 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         self.events: dict[str, list[Callable]] = {k: [] for k in EVENTS}
+        self._event_origins: dict[Callable, str] = {}
 
-    def _register_event(self, event_name: str, method: Callable) -> None:
+    def _register_event(
+        self, event_name: str, method: Callable, plugin_name: str | None = None
+    ) -> None:
         """Register a method for an event."""
         utils.insort(
             self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0)
         )
+        if plugin_name:
+            try:
+                self._event_origins[method] = plugin_name
+            except TypeError:  # If the method is somehow not hashable.
+                pass
 
     def __getitem__(self, key: str) -> BasePlugin:
         return super().__getitem__(key)
@@ -470,7 +478,7 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
         for event_name in (x for x in dir(value) if x.startswith('on_')):
             method = getattr(value, event_name, None)
             if callable(method):
-                self._register_event(event_name[3:], method)
+                self._register_event(event_name[3:], method, plugin_name=key)
 
     @overload
     def run_event(self, name: str, **kwargs) -> Any:
@@ -490,10 +498,10 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
         be modified by the event method.
         """
         pass_item = item is not None
-        events = self.events[name]
-        if events:
-            log.debug(f'Running {len(events)} `{name}` events')
-        for method in events:
+        for method in self.events[name]:
+            if log.getEffectiveLevel() <= logging.DEBUG:
+                plugin_name = self._event_origins.get(method, '<unknown>')
+                log.debug(f"Running `{name}` event from plugin '{plugin_name}'")
             if pass_item:
                 result = method(item, **kwargs)
             else:
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index b1c4df5a..01d2f639 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -322,7 +322,7 @@ class File:
 _default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/'])
 
 
-def _set_exclusions(files: Iterable[File], config: MkDocsConfig) -> None:
+def set_exclusions(files: Iterable[File], config: MkDocsConfig) -> None:
     """Re-calculate which files are excluded, based on the patterns in the config."""
     exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs')
     exclude = _default_exclude + exclude if exclude else _default_exclude
@@ -362,7 +362,7 @@ def get_files(config: MkDocsConfig) -> Files:
             files.append(file)
             prev_file = file
 
-    _set_exclusions(files, config)
+    set_exclusions(files, config)
     # Skip README.md if an index file also exists in dir (part 2)
     for a, b in conflicting_files:
         if b.inclusion.is_included():
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 25d3e97b..d122c821 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,10 +1,10 @@
 from __future__ import annotations
 
 import logging
-import warnings
 from typing import TYPE_CHECKING, Iterator, TypeVar
 from urllib.parse import urlsplit
 
+from mkdocs.exceptions import BuildError
 from mkdocs.structure import StructureItem
 from mkdocs.structure.pages import Page
 from mkdocs.utils import nest_paths
@@ -129,7 +129,9 @@ class Link(StructureItem):
 def get_navigation(files: Files, config: MkDocsConfig) -> Navigation:
     """Build site navigation from config and files."""
     documentation_pages = files.documentation_pages()
-    nav_config = config['nav'] or nest_paths(f.src_uri for f in documentation_pages)
+    nav_config = config['nav'] or nest_paths(
+        f.src_uri for f in documentation_pages if f.inclusion.is_in_nav()
+    )
     items = _data_to_navigation(nav_config, files, config)
     if not isinstance(items, list):
         items = [items]
@@ -202,20 +204,9 @@ def _data_to_navigation(data, files: Files, config: MkDocsConfig):
             )
         page = file.page
         if page is not None:
-            if isinstance(page, Page):
-                if type(page) is not Page:  # Strict subclass
-                    return page
-                warnings.warn(
-                    "A plugin has set File.page to an instance of Page and it got overwritten. "
-                    "The behavior of this will change in MkDocs 1.6.",
-                    DeprecationWarning,
-                )
-            else:
-                warnings.warn(  # type: ignore[unreachable]
-                    "A plugin has set File.page to a type other than Page. "
-                    "This will be an error in MkDocs 1.6.",
-                    DeprecationWarning,
-                )
+            if not isinstance(page, Page):
+                raise BuildError("A plugin has set File.page to a type other than Page.")
+            return page
         return Page(title, file, config)
     return Link(title, path)
 
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index b7204b94..84c1430f 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -392,6 +392,9 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
                 )
 
         if warning:
+            if self.file.inclusion.is_excluded():
+                warning_level = min(logging.INFO, warning_level)
+
             # There was no match, so try to guess what other file could've been intended.
             if warning_level > logging.DEBUG:
                 suggest_url = ''
@@ -414,7 +417,10 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
         assert target_uri is not None
         assert target_file is not None
         if target_file.inclusion.is_excluded():
-            warning_level = min(logging.INFO, self.config.validation.links.not_found)
+            if self.file.inclusion.is_excluded():
+                warning_level = logging.DEBUG
+            else:
+                warning_level = min(logging.INFO, self.config.validation.links.not_found)
             warning = (
                 f"Doc file '{self.file.src_uri}' contains a link to "
                 f"'{target_uri}' which is excluded from the built site."
diff --git a/mkdocs/utils/yaml.py b/mkdocs/utils/yaml.py
index bad00d01..b0c0e7ad 100644
--- a/mkdocs/utils/yaml.py
+++ b/mkdocs/utils/yaml.py
@@ -6,10 +6,10 @@ import os
 import os.path
 from typing import IO, TYPE_CHECKING, Any
 
-import mergedeep
+import mergedeep  # type: ignore
 import yaml
 import yaml.constructor
-import yaml_env_tag
+import yaml_env_tag  # type: ignore
 
 from mkdocs import exceptions
 
diff --git a/pyproject.toml b/pyproject.toml
index c0da964c..1d966e5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -150,6 +150,7 @@ matrix.type.features = [
 [tool.hatch.envs.types]
 dependencies = [
     "mypy",
+    "types-babel",
     "types-Jinja2",
     "types-Markdown",
     "types-PyYAML",
@@ -235,7 +236,6 @@ ignore = ["E501", "E731"]
 allow-dict-calls-with-keyword-arguments = true
 
 [tool.mypy]
-ignore_missing_imports = true
 warn_unreachable = true
 no_implicit_optional = true
 show_error_codes = true


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
index c480eaa2..96ca4f81 100644
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -102,7 +102,7 @@ class PathAssertionMixin:
     """
 
     def assertPathsEqual(self, a, b, msg=None):
-        self.assertEqual(a.replace(os.sep, '/'), b.replace(os.sep, '/'))
+        self.assertEqual(a.replace(os.sep, '/'), b.replace(os.sep, '/'), msg=msg)
 
     def assertPathExists(self, *parts):
         path = os.path.join(*parts)
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 8e128cf4..275aad37 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -301,8 +301,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             "WARNING:mkdocs.commands.build:Template skipped: 'missing.html' not found in docs_dir.",
         )
 
-    @mock.patch('mkdocs.commands.build.open', side_effect=OSError('Error message.'))
-    def test_skip_ioerror_extra_template(self, mock_open):
+    @mock.patch('mkdocs.commands.build.open', mock.Mock(side_effect=OSError('Error message.')))
+    def test_skip_ioerror_extra_template(self):
         cfg = load_config()
         fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)]
         files = Files(fs)
@@ -582,7 +582,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     @tempdir(
         files={
             'test/foo.md': 'page1 content, [bar](bar.md)',
-            'test/bar.md': 'page2 content, [baz](baz.md)',
+            'test/bar.md': 'page2 content, [baz](baz.md), [nonexistent](nonexistent.md)',
             'test/baz.md': 'page3 content, [foo](foo.md)',
             '.zoo.md': 'page4 content',
         }
@@ -609,7 +609,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         server = testing_server(site_dir, mount_path='/documentation/')
         with self.subTest(live_server=server):
             expected_logs = '''
-                INFO:Doc file 'test/bar.md' contains a link to 'test/baz.md' which is excluded from the built site.
+                INFO:Doc file 'test/bar.md' contains a relative link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
                 INFO:The following pages are being built only for the preview but will be excluded from `mkdocs build` per `exclude_docs`:
                   - http://localhost:123/documentation/.zoo.html
diff --git a/mkdocs/tests/config/config_options_legacy_tests.py b/mkdocs/tests/config/config_options_legacy_tests.py
index 4918fab4..8d6da457 100644
--- a/mkdocs/tests/config/config_options_legacy_tests.py
+++ b/mkdocs/tests/config/config_options_legacy_tests.py
@@ -9,7 +9,7 @@ import sys
 import textwrap
 import unittest
 from typing import Any
-from unittest.mock import patch
+from unittest import mock
 
 import mkdocs
 from mkdocs.config import base
@@ -1364,8 +1364,8 @@ class ConfigItemsTest(TestCase):
 
 
 class MarkdownExtensionsTest(TestCase):
-    @patch('markdown.Markdown')
-    def test_simple_list(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_simple_list(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
@@ -1377,8 +1377,8 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf['markdown_extensions'], ['foo', 'bar'])
         self.assertEqual(conf['mdx_configs'], {})
 
-    @patch('markdown.Markdown')
-    def test_list_dicts(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_list_dicts(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
@@ -1400,8 +1400,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_mixed_list(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_mixed_list(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
@@ -1421,8 +1421,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_dict_of_dicts(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_dict_of_dicts(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private()
@@ -1444,8 +1444,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_builtins(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_builtins(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
             mdx_configs = c.Private()
@@ -1483,8 +1483,8 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf['markdown_extensions'], ['meta', 'toc'])
         self.assertEqual(conf['mdx_configs'], {'toc': {'permalink': True}})
 
-    @patch('markdown.Markdown')
-    def test_configkey(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_configkey(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions(configkey='bar')
             bar = c.Private()
@@ -1524,16 +1524,16 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf['markdown_extensions'], [])
         self.assertEqual(conf['mdx_configs'], {})
 
-    @patch('markdown.Markdown')
-    def test_not_list(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_not_list(self):
         class Schema:
             option = c.MarkdownExtensions()
 
         with self.expect_error(option="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, {'option': 'not a list'})
 
-    @patch('markdown.Markdown')
-    def test_invalid_config_option(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_config_option(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
 
@@ -1547,8 +1547,8 @@ class MarkdownExtensionsTest(TestCase):
         ):
             self.get_config(Schema, config)
 
-    @patch('markdown.Markdown')
-    def test_invalid_config_item(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_config_item(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
 
@@ -1560,8 +1560,8 @@ class MarkdownExtensionsTest(TestCase):
         with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, config)
 
-    @patch('markdown.Markdown')
-    def test_invalid_dict_item(self, mock_md):
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_dict_item(self):
         class Schema:
             markdown_extensions = c.MarkdownExtensions()
 
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 4ea010f1..ecab8707 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -10,7 +10,7 @@ import sys
 import textwrap
 import unittest
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar, Union
-from unittest.mock import patch
+from unittest import mock
 
 if TYPE_CHECKING:
     from typing_extensions import assert_type
@@ -1663,8 +1663,8 @@ class NestedSubConfigTest(TestCase):
 
 
 class MarkdownExtensionsTest(TestCase):
-    @patch('markdown.Markdown')
-    def test_simple_list(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_simple_list(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private[Dict[str, dict]]()
@@ -1678,8 +1678,8 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf.markdown_extensions, ['foo', 'bar'])
         self.assertEqual(conf.mdx_configs, {})
 
-    @patch('markdown.Markdown')
-    def test_list_dicts(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_list_dicts(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private[Dict[str, dict]]()
@@ -1701,8 +1701,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_mixed_list(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_mixed_list(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private[Dict[str, dict]]()
@@ -1722,8 +1722,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_dict_of_dicts(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_dict_of_dicts(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
             mdx_configs = c.Private[Dict[str, dict]]()
@@ -1745,8 +1745,8 @@ class MarkdownExtensionsTest(TestCase):
             },
         )
 
-    @patch('markdown.Markdown')
-    def test_builtins(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_builtins(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
             mdx_configs = c.Private[Dict[str, dict]]()
@@ -1784,8 +1784,8 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf.markdown_extensions, ['meta', 'toc'])
         self.assertEqual(conf.mdx_configs, {'toc': {'permalink': True}})
 
-    @patch('markdown.Markdown')
-    def test_configkey(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_configkey(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions(configkey='bar')
             bar = c.Private[Dict[str, dict]]()
@@ -1825,16 +1825,16 @@ class MarkdownExtensionsTest(TestCase):
         self.assertEqual(conf.markdown_extensions, [])
         self.assertEqual(conf.mdx_configs, {})
 
-    @patch('markdown.Markdown')
-    def test_not_list(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_not_list(self) -> None:
         class Schema(Config):
             option = c.MarkdownExtensions()
 
         with self.expect_error(option="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, {'option': 'not a list'})
 
-    @patch('markdown.Markdown')
-    def test_invalid_config_option(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_config_option(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
@@ -1848,8 +1848,8 @@ class MarkdownExtensionsTest(TestCase):
         ):
             self.get_config(Schema, config)
 
-    @patch('markdown.Markdown')
-    def test_invalid_config_item(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_config_item(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
@@ -1861,8 +1861,8 @@ class MarkdownExtensionsTest(TestCase):
         with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
             self.get_config(Schema, config)
 
-    @patch('markdown.Markdown')
-    def test_invalid_dict_item(self, mock_md) -> None:
+    @mock.patch('markdown.Markdown', mock.Mock())
+    def test_invalid_dict_item(self) -> None:
         class Schema(Config):
             markdown_extensions = c.MarkdownExtensions()
 
@@ -1926,6 +1926,15 @@ class FakePlugin2(BasePlugin[_FakePlugin2Config]):
     supports_multiple_instances = True
 
 
+class _EnabledPluginConfig(Config):
+    enabled = c.Type(bool, default=True)
+    bar = c.Type(int, default=0)
+
+
+class EnabledPlugin(BasePlugin[_EnabledPluginConfig]):
+    pass
+
+
 class ThemePlugin(BasePlugin[_FakePluginConfig]):
     pass
 
@@ -1943,18 +1952,21 @@ class FakeEntryPoint:
         return self.cls
 
 
-@patch(
+@mock.patch(
     'mkdocs.plugins.entry_points',
-    return_value=[
-        FakeEntryPoint('sample', FakePlugin),
-        FakeEntryPoint('sample2', FakePlugin2),
-        FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin),
-        FakeEntryPoint('overridden', FakePlugin2),
-        FakeEntryPoint('readthedocs/overridden', ThemePlugin2),
-    ],
+    mock.Mock(
+        return_value=[
+            FakeEntryPoint('sample', FakePlugin),
+            FakeEntryPoint('sample2', FakePlugin2),
+            FakeEntryPoint('sample-e', EnabledPlugin),
+            FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin),
+            FakeEntryPoint('overridden', FakePlugin2),
+            FakeEntryPoint('readthedocs/overridden', ThemePlugin2),
+        ]
+    ),
 )
 class PluginsTest(TestCase):
-    def test_plugin_config_without_options(self, mock_class) -> None:
+    def test_plugin_config_without_options(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -1978,7 +1990,7 @@ class PluginsTest(TestCase):
         }
         self.assertEqual(plugin.config, expected)
 
-    def test_plugin_config_with_options(self, mock_class) -> None:
+    def test_plugin_config_with_options(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2004,7 +2016,7 @@ class PluginsTest(TestCase):
         }
         self.assertEqual(conf.plugins['sample'].config, expected)
 
-    def test_plugin_config_as_dict(self, mock_class) -> None:
+    def test_plugin_config_as_dict(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2028,7 +2040,7 @@ class PluginsTest(TestCase):
         }
         self.assertEqual(conf.plugins['sample'].config, expected)
 
-    def test_plugin_config_with_explicit_theme_namespace(self, mock_class) -> None:
+    def test_plugin_config_with_explicit_theme_namespace(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2045,7 +2057,7 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'})
         self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin)
 
-    def test_plugin_config_with_deduced_theme_namespace(self, mock_class) -> None:
+    def test_plugin_config_with_deduced_theme_namespace(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2060,7 +2072,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins='The "sub_plugin" plugin is not installed'):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_with_deduced_theme_namespace_overridden(self, mock_class) -> None:
+    def test_plugin_config_with_deduced_theme_namespace_overridden(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2077,7 +2089,7 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'overridden'})
         self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)
 
-    def test_plugin_config_with_explicit_empty_namespace(self, mock_class) -> None:
+    def test_plugin_config_with_explicit_empty_namespace(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2094,7 +2106,50 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'overridden'})
         self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)
 
-    def test_plugin_config_with_multiple_instances(self, mock_class) -> None:
+    def test_plugin_config_enabled_for_any_plugin(self) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': False, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), set())
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': True, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample'})
+        self.assertEqual(conf.plugins['sample'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': 5}}}
+        with self.expect_error(
+            plugins="Plugin 'sample' option 'enabled': Expected boolean but received: <class 'int'>"
+        ):
+            self.get_config(Schema, cfg)
+
+    def test_plugin_config_enabled_for_plugin_with_setting(self) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': False, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample-e'})
+        self.assertEqual(conf.plugins['sample-e'].config.enabled, False)
+        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': True, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample-e'})
+        self.assertEqual(conf.plugins['sample-e'].config.enabled, True)
+        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': 5}}}
+        with self.expect_error(
+            plugins="Plugin 'sample-e' option 'enabled': Expected type: <class 'bool'> but received: <class 'int'>"
+        ):
+            self.get_config(Schema, cfg)
+
+    def test_plugin_config_with_multiple_instances(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2114,7 +2169,7 @@ class PluginsTest(TestCase):
         self.assertEqual(conf.plugins['sample2'].config['bar'], 42)
         self.assertEqual(conf.plugins['sample2 #2'].config['bar'], 0)
 
-    def test_plugin_config_with_multiple_instances_and_warning(self, mock_class) -> None:
+    def test_plugin_config_with_multiple_instances_and_warning(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
             plugins = c.Plugins(theme_key='theme')
@@ -2145,7 +2200,7 @@ class PluginsTest(TestCase):
                 {'readthedocs/sub_plugin', 'readthedocs/sub_plugin #2', 'sample2', 'sample2 #2'},
             )
 
-    def test_plugin_config_empty_list_with_empty_default(self, mock_class) -> None:
+    def test_plugin_config_empty_list_with_empty_default(self) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=[])
 
@@ -2155,7 +2210,7 @@ class PluginsTest(TestCase):
         self.assertIsInstance(conf.plugins, PluginCollection)
         self.assertEqual(len(conf.plugins), 0)
 
-    def test_plugin_config_empty_list_with_default(self, mock_class) -> None:
+    def test_plugin_config_empty_list_with_default(self) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=['sample'])
 
@@ -2166,7 +2221,7 @@ class PluginsTest(TestCase):
         self.assertIsInstance(conf.plugins, PluginCollection)
         self.assertEqual(len(conf.plugins), 0)
 
-    def test_plugin_config_none_with_empty_default(self, mock_class) -> None:
+    def test_plugin_config_none_with_empty_default(self) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=[])
 
@@ -2176,7 +2231,7 @@ class PluginsTest(TestCase):
         self.assertIsInstance(conf.plugins, PluginCollection)
         self.assertEqual(len(conf.plugins), 0)
 
-    def test_plugin_config_none_with_default(self, mock_class) -> None:
+    def test_plugin_config_none_with_default(self) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=['sample'])
 
@@ -2194,7 +2249,7 @@ class PluginsTest(TestCase):
         }
         self.assertEqual(conf.plugins['sample'].config, expected)
 
-    def test_plugin_config_uninstalled(self, mock_class) -> None:
+    def test_plugin_config_uninstalled(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2202,7 +2257,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins='The "uninstalled" plugin is not installed'):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_not_list(self, mock_class) -> None:
+    def test_plugin_config_not_list(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2210,7 +2265,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins="Invalid Plugins configuration. Expected a list or dict."):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_multivalue_dict(self, mock_class) -> None:
+    def test_plugin_config_multivalue_dict(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2236,7 +2291,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins="Invalid Plugins configuration"):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_not_string_or_dict(self, mock_class) -> None:
+    def test_plugin_config_not_string_or_dict(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2246,7 +2301,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins="'('not a string or dict',)' is not a valid plugin name."):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_options_not_dict(self, mock_class) -> None:
+    def test_plugin_config_options_not_dict(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
@@ -2256,7 +2311,7 @@ class PluginsTest(TestCase):
         with self.expect_error(plugins="Invalid config options for the 'sample' plugin."):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_sub_error(self, mock_class) -> None:
+    def test_plugin_config_sub_error(self) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=['sample'])
 
@@ -2270,7 +2325,7 @@ class PluginsTest(TestCase):
         ):
             self.get_config(Schema, cfg)
 
-    def test_plugin_config_sub_warning(self, mock_class) -> None:
+    def test_plugin_config_sub_warning(self) -> None:
         class Schema(Config):
             plugins = c.Plugins()
 
diff --git a/mkdocs/tests/gh_deploy_tests.py b/mkdocs/tests/gh_deploy_tests.py
index 093e6590..67b41aa6 100644
--- a/mkdocs/tests/gh_deploy_tests.py
+++ b/mkdocs/tests/gh_deploy_tests.py
@@ -1,7 +1,7 @@
 import unittest
 from unittest import mock
 
-from ghp_import import GhpError
+from ghp_import import GhpError  # type: ignore
 
 from mkdocs import __version__
 from mkdocs.commands import gh_deploy
@@ -58,77 +58,72 @@ class TestGitHubDeploy(unittest.TestCase):
         expected = (None, None)
         self.assertEqual(expected, gh_deploy._get_remote_url('origin'))
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
-    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
-    @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    def test_deploy(self, mock_import, check_version, get_remote, get_sha, is_repo):
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', mock.Mock(return_value=(None, None)))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version', mock.Mock())
+    @mock.patch('ghp_import.ghp_import', mock.Mock())
+    def test_deploy(self):
         config = load_config(
             remote_branch='test',
         )
         gh_deploy.gh_deploy(config)
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
-    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
-    @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    @mock.patch('os.path.isfile', return_value=False)
-    def test_deploy_no_cname(
-        self, mock_isfile, mock_import, check_version, get_remote, get_sha, is_repo
-    ):
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', mock.Mock(return_value=(None, None)))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version', mock.Mock())
+    @mock.patch('ghp_import.ghp_import', mock.Mock())
+    @mock.patch('os.path.isfile', mock.Mock(return_value=False))
+    def test_deploy_no_cname(self):
         config = load_config(
             remote_branch='test',
         )
         gh_deploy.gh_deploy(config)
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
     @mock.patch(
-        'mkdocs.commands.gh_deploy._get_remote_url', return_value=('git@', 'mkdocs/mkdocs.git')
+        'mkdocs.commands.gh_deploy._get_remote_url',
+        mock.Mock(return_value=('git@', 'mkdocs/mkdocs.git')),
     )
-    @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    def test_deploy_hostname(self, mock_import, check_version, get_remote, get_sha, is_repo):
+    @mock.patch('mkdocs.commands.gh_deploy._check_version', mock.Mock())
+    @mock.patch('ghp_import.ghp_import', mock.Mock())
+    def test_deploy_hostname(self):
         config = load_config(
             remote_branch='test',
         )
         gh_deploy.gh_deploy(config)
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
-    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', mock.Mock(return_value=(None, None)))
     @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    def test_deploy_ignore_version_default(
-        self, mock_import, check_version, get_remote, get_sha, is_repo
-    ):
+    @mock.patch('ghp_import.ghp_import', mock.Mock())
+    def test_deploy_ignore_version_default(self, check_version):
         config = load_config(
             remote_branch='test',
         )
         gh_deploy.gh_deploy(config)
         check_version.assert_called_once()
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
-    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', return_value=(None, None))
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
+    @mock.patch('mkdocs.commands.gh_deploy._get_remote_url', mock.Mock(return_value=(None, None)))
     @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    def test_deploy_ignore_version(self, mock_import, check_version, get_remote, get_sha, is_repo):
+    @mock.patch('ghp_import.ghp_import', mock.Mock())
+    def test_deploy_ignore_version(self, check_version):
         config = load_config(
             remote_branch='test',
         )
         gh_deploy.gh_deploy(config, ignore_version=True)
         check_version.assert_not_called()
 
-    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', return_value=True)
-    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', return_value='shashas')
-    @mock.patch('mkdocs.commands.gh_deploy._check_version')
-    @mock.patch('ghp_import.ghp_import')
-    def test_deploy_error(self, mock_import, check_version, get_sha, is_repo):
-        mock_import.side_effect = GhpError('TestError123')
-
+    @mock.patch('mkdocs.commands.gh_deploy._is_cwd_git_repo', mock.Mock(return_value=True))
+    @mock.patch('mkdocs.commands.gh_deploy._get_current_sha', mock.Mock(return_value='shashas'))
+    @mock.patch('mkdocs.commands.gh_deploy._check_version', mock.Mock())
+    @mock.patch('ghp_import.ghp_import', mock.Mock(side_effect=GhpError('TestError123')))
+    def test_deploy_error(self):
         config = load_config(
             remote_branch='test',
         )
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index 1c301cb5..200efd73 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -79,256 +79,167 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         )
 
     def test_md_file(self):
-        f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
-        self.assertEqual(f.dest_uri, 'foo.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
-        self.assertEqual(f.url, 'foo.html')
-        self.assertEqual(f.name, 'foo')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_file_use_directory_urls(self):
-        f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/')
-        self.assertEqual(f.name, 'foo')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
+                if use_directory_urls:
+                    self.assertEqual(f.dest_uri, 'foo/index.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
+                    self.assertEqual(f.url, 'foo/')
+                else:
+                    self.assertEqual(f.dest_uri, 'foo.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
+                    self.assertEqual(f.url, 'foo.html')
+                self.assertEqual(f.name, 'foo')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_file_nested(self):
-        f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_file_nested_use_directory_urls(self):
-        f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertEqual(f.dest_uri, 'foo/bar/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html')
-        self.assertEqual(f.url, 'foo/bar/')
-        self.assertEqual(f.name, 'bar')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
+                if use_directory_urls:
+                    self.assertEqual(f.dest_uri, 'foo/bar/index.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html')
+                    self.assertEqual(f.url, 'foo/bar/')
+                else:
+                    self.assertEqual(f.dest_uri, 'foo/bar.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
+                    self.assertEqual(f.url, 'foo/bar.html')
+                self.assertEqual(f.name, 'bar')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_index_file(self):
-        f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, 'index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'index.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
+                self.assertEqual(f.dest_uri, 'index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, './')
+                else:
+                    self.assertEqual(f.url, 'index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_readme_index_file(self):
-        f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'README.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, 'index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_index_file_use_directory_urls(self):
-        f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, './')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_readme_index_file_use_directory_urls(self):
-        f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'README.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, './')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'README.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
+                self.assertEqual(f.dest_uri, 'index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, './')
+                else:
+                    self.assertEqual(f.url, 'index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_index_file_nested(self):
-        f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_index_file_nested_use_directory_urls(self):
-        f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/index.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
+                self.assertEqual(f.dest_uri, 'foo/index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, 'foo/')
+                else:
+                    self.assertEqual(f.url, 'foo/index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_static_file(self):
-        f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertTrue(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_static_file_use_directory_urls(self):
-        f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertTrue(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.html')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
+                self.assertEqual(f.dest_uri, 'foo/bar.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
+                self.assertEqual(f.url, 'foo/bar.html')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertTrue(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_media_file(self):
-        f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
-        self.assertEqual(f.url, 'foo/bar.jpg')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_media_file_use_directory_urls(self):
-        f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
-        self.assertEqual(f.url, 'foo/bar.jpg')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.jpg')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
+                self.assertEqual(f.dest_uri, 'foo/bar.jpg')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
+                self.assertEqual(f.url, 'foo/bar.jpg')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_javascript_file(self):
-        f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertEqual(f.dest_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
-        self.assertEqual(f.url, 'foo/bar.js')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertTrue(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_javascript_file_use_directory_urls(self):
-        f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertEqual(f.dest_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
-        self.assertEqual(f.url, 'foo/bar.js')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertTrue(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.js')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
+                self.assertEqual(f.dest_uri, 'foo/bar.js')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
+                self.assertEqual(f.url, 'foo/bar.js')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertTrue(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_css_file(self):
-        f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertEqual(f.dest_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
-        self.assertEqual(f.url, 'foo/bar.css')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertTrue(f.is_css())
-
-    def test_css_file_use_directory_urls(self):
-        f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertEqual(f.dest_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
-        self.assertEqual(f.url, 'foo/bar.css')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertTrue(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.css')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
+                self.assertEqual(f.dest_uri, 'foo/bar.css')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
+                self.assertEqual(f.url, 'foo/bar.css')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertTrue(f.is_css())
 
     def test_file_name_with_space(self):
         f = File('foo bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py
index 44ae2adc..04846111 100644
--- a/mkdocs/tests/structure/nav_tests.py
+++ b/mkdocs/tests/structure/nav_tests.py
@@ -3,7 +3,7 @@
 import sys
 import unittest
 
-from mkdocs.structure.files import File, Files
+from mkdocs.structure.files import File, Files, set_exclusions
 from mkdocs.structure.nav import Section, _get_by_type, get_navigation
 from mkdocs.structure.pages import Page
 from mkdocs.tests.base import dedent, load_config
@@ -378,6 +378,35 @@ class SiteNavigationTests(unittest.TestCase):
         self.assertEqual(len(site_navigation.pages), 7)
         self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')")
 
+    def test_nav_with_exclusion(self):
+        expected = dedent(
+            """
+            Page(title=[blank], url='index.html')
+            Section(title='About')
+                Page(title=[blank], url='about/license.html')
+                Page(title=[blank], url='about/release-notes.html')
+            Section(title='Api guide')
+                Page(title=[blank], url='api-guide/running.html')
+                Page(title=[blank], url='api-guide/testing.html')
+            """
+        )
+        cfg = load_config(use_directory_urls=False, not_in_nav='*ging.md\n/foo.md\n')
+        fs = [
+            'index.md',
+            'foo.md',
+            'about/license.md',
+            'about/release-notes.md',
+            'api-guide/debugging.md',
+            'api-guide/running.md',
+            'api-guide/testing.md',
+        ]
+        files = Files([File(s, cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) for s in fs])
+        set_exclusions(files, cfg)
+        site_navigation = get_navigation(files, cfg)
+        self.assertEqual(str(site_navigation).strip(), expected)
+        self.assertEqual(len(site_navigation.items), 3)
+        self.assertEqual(len(site_navigation.pages), 5)
+
     def test_nav_page_subclass(self):
         class PageSubclass(Page):
             pass