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:
Oleh Prypin
2022-08-23 01:05:23 +02:00
parent b608bb32fb
commit ee844242d1
2 changed files with 232 additions and 56 deletions

View File

@@ -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}

View File

@@ -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):