diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 82465251..25499ac1 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -3,7 +3,6 @@ import os import sys import traceback from collections import namedtuple -from collections.abc import Sequence from urllib.parse import urlsplit, urlunsplit import markdown @@ -48,37 +47,6 @@ class SubConfig(BaseConfigOption): return config -class ConfigItems(BaseConfigOption): - """ - Config Items Option - - Validates a list of mappings that all must match the same set of - options. - """ - - def __init__(self, *config_options, **kwargs): - BaseConfigOption.__init__(self) - self.item_config = SubConfig(*config_options) - self.required = kwargs.get('required', False) - - def __repr__(self): - return f'{self.__class__.__name__}: {self.item_config}' - - def run_validation(self, value): - if value is None: - if self.required: - raise ValidationError("Required configuration not provided.") - else: - return () - - if not isinstance(value, Sequence): - raise ValidationError( - f'Expected a sequence of mappings, but a ' f'{type(value)} was given.' - ) - - return [self.item_config.validate(item) for item in value] - - class OptionallyRequired(BaseConfigOption): """ A subclass of BaseConfigOption that adds support for default values and @@ -114,6 +82,70 @@ class OptionallyRequired(BaseConfigOption): return self.run_validation(value) +class ListOfItems(BaseConfigOption): + """ + Validates a homogeneous list of items. + + E.g. for `config_options.ListOfItems(config_options.Type(int))` a valid item is `[1, 2, 3]`. + """ + + required = False + + def __init__(self, option_type: BaseConfigOption, default=[]): + super().__init__() + self.default = default + self.option_type = option_type + self.option_type.warnings = self.warnings + + def __repr__(self): + return f'{type(self).__name__}: {self.option_type}' + + def pre_validation(self, config, key_name): + self._config = config + self._key_name = key_name + + def run_validation(self, value): + if value is None and not self.required: + return self.default + + if not isinstance(value, list): + raise ValidationError(f'Expected a list of items, but a {type(value)} was given.') + + fake_config = Config(()) + try: + fake_config.config_file_path = self._config.config_file_path + except AttributeError: + pass + + # Emulate a config-like environment for pre_validation and post_validation. + parent_key_name = getattr(self, '_key_name', '') + fake_keys = [f'{parent_key_name}[{i}]' for i in range(len(value))] + fake_config.data = dict(zip(fake_keys, value)) + + for key_name in fake_config: + self.option_type.pre_validation(fake_config, key_name) + for key_name in fake_config: + # Specifically not running `validate` to avoid the OptionallyRequired effect. + fake_config[key_name] = self.option_type.run_validation(fake_config[key_name]) + for key_name in fake_config: + self.option_type.post_validation(fake_config, key_name) + + return [fake_config[k] for k in fake_keys] + + +class ConfigItems(ListOfItems): + """ + Config Items Option + + Validates a list of mappings that all must match the same set of + options. + """ + + def __init__(self, *config_options, required=False, validate=False): + super().__init__(SubConfig(*config_options, validate=validate)) + self.required = required + + class Type(OptionallyRequired): """ Type Config Option @@ -407,36 +439,20 @@ class File(FilesystemObject): name = 'file' -class ListOfPaths(OptionallyRequired): +class ListOfPaths(ListOfItems): """ List of Paths Config Option A list of file system paths. Raises an error if one of the paths does not exist. + + For greater flexibility, prefer ListOfItems, e.g. to require files specifically: + + config_options.ListOfItems(config_options.File(exists=True)) """ def __init__(self, default=[], required=False): - self.config_dir = None - super().__init__(default, required) - - def pre_validation(self, config, key_name): - self.config_dir = ( - os.path.dirname(config.config_file_path) if config.config_file_path else None - ) - - def run_validation(self, value): - if not isinstance(value, list): - raise ValidationError(f"Expected a list, got {type(value)}") - if len(value) == 0: - return - paths = [] - for path in value: - if self.config_dir and not os.path.isabs(path): - path = os.path.join(self.config_dir, path) - if not os.path.exists(path): - raise ValidationError(f"The path {path} does not exist.") - path = os.path.abspath(path) - paths.append(path) - return paths + super().__init__(FilesystemObject(exists=True), default) + self.required = required class SiteDir(Dir): @@ -482,7 +498,7 @@ class Theme(BaseConfigOption): super().__init__() self.default = default - def validate(self, value): + def run_validation(self, value): if value is None and self.default is not None: value = {'name': self.default} diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 30160daa..0bcf7fb8 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -1,3 +1,4 @@ +import contextlib import os import sys import textwrap @@ -11,6 +12,28 @@ from mkdocs.tests.base import tempdir from mkdocs.utils import yaml_load +class UnexpectedError(Exception): + pass + + +class TestCase(unittest.TestCase): + @contextlib.contextmanager + def expect_error(self, **kwargs): + [(key, msg)] = kwargs.items() + with self.assertRaises(UnexpectedError) as cm: + yield + self.assertEqual(f'{key}="{msg}"', str(cm.exception)) + + def get_config(self, schema, cfg, warnings={}): + config = Config(schema) + config.load_dict(cfg) + actual_errors, actual_warnings = config.validate() + if actual_errors: + raise UnexpectedError(', '.join(f'{key}="{msg}"' for key, msg in actual_errors)) + self.assertEqual(warnings, dict(actual_warnings)) + return config + + class OptionallyRequiredTest(unittest.TestCase): def test_empty(self): @@ -358,6 +381,69 @@ class RepoURLTest(unittest.TestCase): self.assertEqual(config.get('edit_uri'), 'edit/master/docs/') +class ListOfItemsTest(TestCase): + def test_int_type(self): + schema = [ + ('option', config_options.ListOfItems(config_options.Type(int))), + ] + cfg = self.get_config(schema, {'option': [1, 2, 3]}) + self.assertEqual(cfg['option'], [1, 2, 3]) + + with self.expect_error( + option="Expected type: but received: " + ): + cfg = self.get_config(schema, {'option': [1, None, 3]}) + + def test_combined_float_type(self): + schema = [ + ('option', config_options.ListOfItems(config_options.Type((int, float)))), + ] + cfg = self.get_config(schema, {'option': [1.4, 2, 3]}) + self.assertEqual(cfg['option'], [1.4, 2, 3]) + + with self.expect_error( + option="Expected type: (, ) but received: " + ): + self.get_config(schema, {'option': ['a']}) + + def test_list_default(self): + schema = [ + ('option', config_options.ListOfItems(config_options.Type(int))), + ] + cfg = self.get_config(schema, {}) + self.assertEqual(cfg['option'], []) + + cfg = self.get_config(schema, {'option': None}) + self.assertEqual(cfg['option'], []) + + def test_none_default(self): + schema = [ + ('option', config_options.ListOfItems(config_options.Type(str), default=None)), + ] + cfg = self.get_config(schema, {}) + self.assertEqual(cfg['option'], None) + + cfg = self.get_config(schema, {'option': None}) + self.assertEqual(cfg['option'], None) + + cfg = self.get_config(schema, {'option': ['foo']}) + self.assertEqual(cfg['option'], ['foo']) + + def test_string_not_a_list_of_strings(self): + schema = [ + ('option', config_options.ListOfItems(config_options.Type(str))), + ] + with self.expect_error(option="Expected a list of items, but a was given."): + self.get_config(schema, {'option': 'foo'}) + + def test_post_validation_error(self): + schema = [ + ('option', config_options.ListOfItems(config_options.IpAddress())), + ] + with self.expect_error(option="'asdf' is not a valid port"): + self.get_config(schema, {'option': ["localhost:8000", "1.2.3.4:asdf"]}) + + class FilesystemObjectTest(unittest.TestCase): def test_valid_dir(self): for cls in config_options.Dir, config_options.FilesystemObject: @@ -515,6 +601,12 @@ class ListOfPathsTest(unittest.TestCase): with self.assertRaises(config_options.ValidationError): option.validate(paths) + def test_non_path(self): + paths = [os.path.dirname(__file__), None] + option = config_options.ListOfPaths() + with self.assertRaises(config_options.ValidationError): + option.validate(paths) + def test_empty_list(self): paths = [] option = config_options.ListOfPaths() @@ -939,6 +1031,74 @@ class SubConfigTest(unittest.TestCase): assert res == dict(c='foo') +class ConfigItemsTest(TestCase): + def test_non_required(self): + schema = { + 'sub': config_options.ConfigItems( + ('opt', config_options.Type(int)), + validate=True, + ), + }.items() + + cfg = self.get_config(schema, {}) + self.assertEqual(cfg['sub'], []) + + cfg = self.get_config(schema, {'sub': None}) + self.assertEqual(cfg['sub'], []) + + cfg = self.get_config(schema, {'sub': [{'opt': 1}, {}]}) + self.assertEqual(cfg['sub'], [{'opt': 1}, {'opt': None}]) + + def test_required(self): + schema = { + 'sub': config_options.ConfigItems( + ('opt', config_options.Type(str, required=True)), + validate=True, + ), + }.items() + + cfg = self.get_config(schema, {}) + self.assertEqual(cfg['sub'], []) + + cfg = self.get_config(schema, {'sub': None}) + self.assertEqual(cfg['sub'], []) + + with self.expect_error( + sub="Sub-option 'opt' configuration error: Expected type: but received: " + ): + cfg = self.get_config(schema, {'sub': [{'opt': 1}, {}]}) + + def test_common(self): + for required in False, True: + with self.subTest(required=required): + schema = { + 'sub': config_options.ConfigItems( + ('opt', config_options.Type(int, required=required)), + validate=True, + ), + }.items() + + cfg = self.get_config(schema, {'sub': None}) + self.assertEqual(cfg['sub'], []) + + cfg = self.get_config(schema, {'sub': []}) + + cfg = self.get_config(schema, {'sub': [{'opt': 1}, {'opt': 2}]}) + self.assertEqual(cfg['sub'], [{'opt': 1}, {'opt': 2}]) + + with self.expect_error( + sub="Sub-option 'opt' configuration error: " + "Expected type: but received: " + ): + cfg = self.get_config(schema, {'sub': [{'opt': 'z'}, {'opt': 2}]}) + + with self.expect_error( + sub="The configuration is invalid. The expected type was a key value mapping " + "(a python dict) but we got an object of type: " + ): + cfg = self.get_config(schema, {'sub': [1, 2]}) + + class MarkdownExtensionsTest(unittest.TestCase): @patch('markdown.Markdown') def test_simple_list(self, mockMd):