mirror of
https://github.com/mkdocs/mkdocs.git
synced 2026-03-27 09:58:31 +07:00
Add generic ListOfItems config, refactor existing configs based on it
A string is no longer passable as a sequence of strings.
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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: <class 'int'> but received: <class 'NoneType'>"
|
||||
):
|
||||
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: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
|
||||
):
|
||||
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 <class 'str'> was given."):
|
||||
self.get_config(schema, {'option': 'foo'})
|
||||
|
||||
def test_post_validation_error(self):
|
||||
schema = [
|
||||
('option', config_options.ListOfItems(config_options.IpAddress())),
|
||||
]
|
||||
with self.expect_error(option="'asdf' is not a valid port"):
|
||||
self.get_config(schema, {'option': ["localhost:8000", "1.2.3.4:asdf"]})
|
||||
|
||||
|
||||
class FilesystemObjectTest(unittest.TestCase):
|
||||
def test_valid_dir(self):
|
||||
for cls in config_options.Dir, config_options.FilesystemObject:
|
||||
@@ -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: <class 'str'> but received: <class 'int'>"
|
||||
):
|
||||
cfg = self.get_config(schema, {'sub': [{'opt': 1}, {}]})
|
||||
|
||||
def test_common(self):
|
||||
for required in False, True:
|
||||
with self.subTest(required=required):
|
||||
schema = {
|
||||
'sub': config_options.ConfigItems(
|
||||
('opt', config_options.Type(int, required=required)),
|
||||
validate=True,
|
||||
),
|
||||
}.items()
|
||||
|
||||
cfg = self.get_config(schema, {'sub': None})
|
||||
self.assertEqual(cfg['sub'], [])
|
||||
|
||||
cfg = self.get_config(schema, {'sub': []})
|
||||
|
||||
cfg = self.get_config(schema, {'sub': [{'opt': 1}, {'opt': 2}]})
|
||||
self.assertEqual(cfg['sub'], [{'opt': 1}, {'opt': 2}])
|
||||
|
||||
with self.expect_error(
|
||||
sub="Sub-option 'opt' configuration error: "
|
||||
"Expected type: <class 'int'> but received: <class 'str'>"
|
||||
):
|
||||
cfg = self.get_config(schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})
|
||||
|
||||
with self.expect_error(
|
||||
sub="The configuration is invalid. The expected type was a key value mapping "
|
||||
"(a python dict) but we got an object of type: <class 'int'>"
|
||||
):
|
||||
cfg = self.get_config(schema, {'sub': [1, 2]})
|
||||
|
||||
|
||||
class MarkdownExtensionsTest(unittest.TestCase):
|
||||
@patch('markdown.Markdown')
|
||||
def test_simple_list(self, mockMd):
|
||||
|
||||
Reference in New Issue
Block a user