diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index b433e851..25d0216a 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -384,25 +384,21 @@ The defaults of some of the behaviors already differ from MkDocs 1.4 and below - > > ```yaml > validation: -> nav: -> absolute_links: ignore -> links: -> absolute_links: ignore -> unrecognized_links: ignore +> absolute_links: ignore +> unrecognized_links: ignore > ``` >! EXAMPLE: **Recommended settings for most sites (maximal strictness):** > > ```yaml > validation: -> nav: -> omitted_files: warn -> absolute_links: warn -> links: -> absolute_links: warn -> unrecognized_links: warn +> omitted_files: warn +> absolute_links: warn +> unrecognized_links: warn > ``` +Note how in the above examples we omitted the 'nav' and 'links' keys. Here `absolute_links:` means setting both `nav: absolute_links:` and `links: absolute_links:`. + Full list of values and examples of log messages that they can hide or make more prominent: * `validation.nav.omitted_files` diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 9acda32e..09be435f 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -119,6 +119,28 @@ class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]): return config +class PropagatingSubConfig(SubConfig[SomeConfig], Generic[SomeConfig]): + """A SubConfig that must consist of SubConfigs with defined schemas. + + Any value set on the top config gets moved to sub-configs with matching keys. + """ + + def run_validation(self, value: object): + if isinstance(value, dict): + to_discard = set() + for k1, v1 in self.config_class._schema: + if isinstance(v1, SubConfig): + for k2, _ in v1.config_class._schema: + if k2 in value: + subdict = value.setdefault(k1, {}) + if isinstance(subdict, dict): + to_discard.add(k2) + subdict.setdefault(k2, value[k2]) + for k in to_discard: + del value[k] + return super().run_validation(value) + + class OptionallyRequired(Generic[T], BaseConfigOption[T]): """ Soft-deprecated, do not use. diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index c8152956..3f94eb54 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -166,7 +166,7 @@ class MkDocsConfig(base.Config): links = c.SubConfig(LinksValidation) - validation = c.SubConfig(Validation) + validation = c.PropagatingSubConfig[Validation]() _current_page: mkdocs.structure.pages.Page | None = None """The currently rendered page. Please do not access this and instead diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index e13a20ca..2b9d9af4 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib import copy import io +import logging import os import re import sys @@ -21,6 +22,7 @@ else: import mkdocs from mkdocs.config import config_options as c +from mkdocs.config import defaults from mkdocs.config.base import Config from mkdocs.plugins import BasePlugin, PluginCollection from mkdocs.tests.base import tempdir @@ -1573,6 +1575,94 @@ class SubConfigTest(TestCase): self.assertEqual(passed_config_path, config_path) +class NestedSubConfigTest(TestCase): + def defaults(self): + return { + 'nav': { + 'omitted_files': logging.INFO, + 'not_found': logging.WARNING, + 'absolute_links': logging.INFO, + }, + 'links': { + 'not_found': logging.WARNING, + 'absolute_links': logging.INFO, + 'unrecognized_links': logging.INFO, + }, + } + + class Schema(Config): + validation = c.PropagatingSubConfig[defaults.MkDocsConfig.Validation]() + + def test_unspecified(self) -> None: + for cfg in {}, {'validation': {}}: + with self.subTest(cfg): + conf = self.get_config( + self.Schema, + {}, + ) + self.assertEqual(conf.validation, self.defaults()) + + def test_sets_nested_and_not_nested(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'not_found': 'ignore', 'links': {'absolute_links': 'warn'}}}, + ) + expected = self.defaults() + expected['nav']['not_found'] = logging.DEBUG + expected['links']['not_found'] = logging.DEBUG + expected['links']['absolute_links'] = logging.WARNING + self.assertEqual(conf.validation, expected) + + def test_sets_nested_different(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'not_found': 'ignore', 'links': {'not_found': 'warn'}}}, + ) + expected = self.defaults() + expected['nav']['not_found'] = logging.DEBUG + expected['links']['not_found'] = logging.WARNING + self.assertEqual(conf.validation, expected) + + def test_sets_only_one_nested(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'omitted_files': 'ignore'}}, + ) + expected = self.defaults() + expected['nav']['omitted_files'] = logging.DEBUG + self.assertEqual(conf.validation, expected) + + def test_sets_nested_not_dict(self) -> None: + with self.expect_error( + validation="Sub-option 'links': Sub-option 'unrecognized_links': Expected a string, but a was given." + ): + self.get_config( + self.Schema, + {'validation': {'unrecognized_links': [], 'links': {'absolute_links': 'warn'}}}, + ) + + def test_wrong_key_nested(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'foo': 'warn', 'not_found': 'warn'}}, + warnings=dict(validation="Sub-option 'foo': Unrecognised configuration name: foo"), + ) + expected = self.defaults() + expected['nav']['not_found'] = logging.WARNING + expected['links']['not_found'] = logging.WARNING + expected['foo'] = 'warn' + self.assertEqual(conf.validation, expected) + + def test_wrong_type_nested(self) -> None: + with self.expect_error( + validation="Sub-option 'nav': Sub-option 'omitted_files': Expected one of ['warn', 'info', 'ignore'], got 'hi'" + ): + self.get_config( + self.Schema, + {'validation': {'omitted_files': 'hi'}}, + ) + + class MarkdownExtensionsTest(TestCase): @patch('markdown.Markdown') def test_simple_list(self, mock_md) -> None: