Repository: mkdocs/mkdocs (python)

Issue:
`enabled` setting for all plugins
It seems that so many plugins have an `enabled` setting these days that we may as well just add it in MkDocs. It will not run the plugin at all then. But if a plugin already has this setting explicitly, then MkDocs will not do anything, so the plugin can still decide what to do when it's disabled.

Pull request:
Add an `enabled` setting for all plugins
For every plugin, the user can set `enabled: false` (or something based on an environment variable) and it will not kick in at all.

If the plugin has its own `enabled` config, there's no change for it in that case.

* Closes #3386

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/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/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)
 


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 2c8a33ae..ecab8707 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -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
 
@@ -1949,6 +1958,7 @@ class FakeEntryPoint:
         return_value=[
             FakeEntryPoint('sample', FakePlugin),
             FakeEntryPoint('sample2', FakePlugin2),
+            FakeEntryPoint('sample-e', EnabledPlugin),
             FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin),
             FakeEntryPoint('overridden', FakePlugin2),
             FakeEntryPoint('readthedocs/overridden', ThemePlugin2),
@@ -2096,6 +2106,49 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'overridden'})
         self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)
 
+    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')
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