Add Optional config but make fields in class-based config required

This commit is contained in:
Oleh Prypin
2022-09-12 23:07:24 +02:00
parent a4c1bb14dc
commit ac0efce437
5 changed files with 111 additions and 25 deletions

View File

@@ -138,6 +138,15 @@ class Config(UserDict):
schema[attr_name] = attr
cls._schema = tuple(schema.items())
for attr_name, attr in cls._schema:
attr.required = True
if getattr(attr, '_legacy_required', None) is not None:
raise TypeError(
f"{cls.__name__}.{attr_name}: "
"Setting 'required' is unsupported in class-based configs. "
"All values are required, or can be wrapped into config_options.Optional"
)
def __new__(cls, *args, **kwargs) -> Config:
"""Compatibility: allow referring to `LegacyConfig(...)` constructor as `Config(...)`."""
if cls is Config:

View File

@@ -9,7 +9,7 @@ import traceback
import typing as t
import warnings
from collections import UserString
from typing import Collection, Dict, Generic, List, NamedTuple, TypeVar
from typing import Collection, Dict, Generic, List, NamedTuple, Tuple, TypeVar, Union, overload
from urllib.parse import quote as urlquote
from urllib.parse import urlsplit, urlunsplit
@@ -69,10 +69,19 @@ class OptionallyRequired(Generic[T], BaseConfigOption[T]):
required values. It is a base class for config options.
"""
def __init__(self, default=None, required: bool = False):
@overload
def __init__(self, default=None):
...
@overload
def __init__(self, default=None, *, required: bool):
...
def __init__(self, default=None, required=None):
super().__init__()
self.default = default
self.required = required
self._legacy_required = required
self.required = bool(required)
def validate(self, value):
"""
@@ -100,7 +109,7 @@ class ListOfItems(Generic[T], BaseConfigOption[List[T]]):
E.g. for `config_options.ListOfItems(config_options.Type(int))` a valid item is `[1, 2, 3]`.
"""
required = False
required: Union[bool, None] = None # Only for subclasses to set.
def __init__(self, option_type: BaseConfigOption[T], default: List[T] = []):
super().__init__()
@@ -152,9 +161,18 @@ class ConfigItems(ListOfItems[Config]):
options.
"""
def __init__(self, *config_options: PlainConfigSchemaItem, required: bool = False):
@overload
def __init__(self, *config_options: PlainConfigSchemaItem):
...
@overload
def __init__(self, *config_options: PlainConfigSchemaItem, required: bool):
...
def __init__(self, *config_options: PlainConfigSchemaItem, required=None):
super().__init__(SubConfig(*config_options))
self.required = required
self._legacy_required = required
self.required = bool(required)
class Type(Generic[T], OptionallyRequired[T]):
@@ -164,7 +182,15 @@ class Type(Generic[T], OptionallyRequired[T]):
Validate the type of a config option against a given Python type.
"""
@overload
def __init__(self, type_: t.Type[T], length: t.Optional[int] = None, **kwargs):
...
@overload
def __init__(self, type_: Tuple[t.Type[T], ...], length: t.Optional[int] = None, **kwargs):
...
def __init__(self, type_, length=None, **kwargs):
super().__init__(**kwargs)
self._type = type_
self.length = length
@@ -336,9 +362,17 @@ class URL(OptionallyRequired[str]):
Validate a URL by requiring a scheme is present.
"""
def __init__(self, default='', required: bool = False, is_dir: bool = False):
@overload
def __init__(self, default='', *, is_dir: bool = False):
...
@overload
def __init__(self, default='', *, required: bool, is_dir: bool = False):
...
def __init__(self, default='', required=None, is_dir: bool = False):
self.is_dir = is_dir
super().__init__(default, required)
super().__init__(default, required=required)
def run_validation(self, value):
if value == '':
@@ -357,6 +391,40 @@ class URL(OptionallyRequired[str]):
raise ValidationError("The URL isn't valid, it should include the http:// (scheme)")
class Optional(Generic[T], BaseConfigOption[Union[T, None]]):
"""Wraps a field and makes a None value possible for it when no value is set.
E.g. `my_field = config_options.Optional(config_options.Type(str))`
"""
def __init__(self, config_option: BaseConfigOption[T]):
super().__init__()
self.option = config_option
self.warnings = config_option.warnings
def __getattr__(self, key):
if key in ('option', 'warnings'):
raise AttributeError
return getattr(self.option, key)
def pre_validation(self, config, key_name):
return self.option.pre_validation(config, key_name)
def run_validation(self, value):
if value is None:
return self.default
return self.option.validate(value)
def post_validation(self, config, key_name):
result = self.option.post_validation(config, key_name)
self.warnings = self.option.warnings
return result
def reset_warnings(self):
self.option.reset_warnings()
self.warnings = self.option.warnings
class RepoURL(URL):
def __init__(self, *args, **kwargs):
warnings.warn(
@@ -418,7 +486,7 @@ class EditURI(Type[str]):
config[key_name] = edit_uri
class EditURITemplate(OptionallyRequired[str]):
class EditURITemplate(BaseConfigOption[str]):
class Formatter(string.Formatter):
def convert_field(self, value, conversion):
if conversion == 'q':
@@ -551,7 +619,15 @@ class ListOfPaths(ListOfItems[str]):
config_options.ListOfItems(config_options.File(exists=True))
"""
def __init__(self, default=[], required: bool = False):
@overload
def __init__(self, default=[]):
...
@overload
def __init__(self, default=[], *, required: bool):
...
def __init__(self, default=[], required=None):
super().__init__(FilesystemObject(exists=True), default)
self.required = required
@@ -696,7 +772,7 @@ class Nav(OptionallyRequired):
return f"a {type(value).__name__}: {value!r}"
class Private(OptionallyRequired):
class Private(BaseConfigOption):
"""
Private Config Option
@@ -704,7 +780,8 @@ class Private(OptionallyRequired):
"""
def run_validation(self, value):
raise ValidationError('For internal use only.')
if value is not None:
raise ValidationError('For internal use only.')
class MarkdownExtensions(OptionallyRequired[List[str]]):

View File

@@ -14,23 +14,23 @@ def get_schema() -> base.PlainConfigSchema:
class MkDocsConfig(base.Config):
"""The configuration of MkDocs itself (the root object of mkdocs.yml)."""
config_file_path: str = c.Type(str) # type: ignore[assignment]
config_file_path: str = c.Optional(c.Type(str)) # type: ignore[assignment]
"""Reserved for internal use, stores the mkdocs.yml config file."""
site_name = c.Type(str, required=True)
site_name = c.Type(str)
"""The title to use for the documentation."""
nav = c.Nav()
nav = c.Optional(c.Nav())
"""Defines the structure of the navigation."""
pages = c.Deprecated(removed=True, moved_to='nav')
site_url = c.URL(is_dir=True)
site_url = c.Optional(c.URL(is_dir=True))
"""The full URL to where the documentation will be hosted."""
site_description = c.Type(str)
site_description = c.Optional(c.Type(str))
"""A description for the documentation project that will be added to the
HTML meta tags."""
site_author = c.Type(str)
site_author = c.Optional(c.Type(str))
"""The name of the author to add to the HTML meta tags."""
theme = c.Theme(default='mkdocs')
@@ -42,7 +42,7 @@ class MkDocsConfig(base.Config):
site_dir = c.SiteDir(default='site')
"""The directory where the site will be built to"""
copyright = c.Type(str)
copyright = c.Optional(c.Type(str))
"""A copyright notice to add to the footer of documentation."""
google_analytics = c.Deprecated(
@@ -66,18 +66,18 @@ class MkDocsConfig(base.Config):
True generates nicer URLs, but False is useful if browsing the output on
a filesystem."""
repo_url = c.URL()
repo_url = c.Optional(c.URL())
"""Specify a link to the project source repo to be included
in the documentation pages."""
repo_name = c.RepoName('repo_url')
repo_name = c.Optional(c.RepoName('repo_url'))
"""A name to use for the link to the project source repo.
Default, If repo_url is unset then None, otherwise
"GitHub", "Bitbucket" or "GitLab" for known url or Hostname
for unknown urls."""
edit_uri_template = c.EditURITemplate('edit_uri')
edit_uri = c.EditURI('repo_url')
edit_uri_template = c.Optional(c.EditURITemplate('edit_uri'))
edit_uri = c.Optional(c.EditURI('repo_url'))
"""Specify a URI to the docs dir in the project source repo, relative to the
repo_url. When set, a link directly to the page in the source repo will
be added to the generated HTML. If repo_url is not set also, this option

View File

@@ -47,7 +47,7 @@ class LangOption(c.OptionallyRequired[List[str]]):
class _PluginConfig(base.Config):
lang = LangOption()
lang = c.Optional(LangOption())
separator = c.Type(str, default=r'[\s\-]+')
min_search_length = c.Type(int, default=3)
prebuild_index = c.Choice((False, True, 'node', 'python'), default=False)

View File

@@ -17,7 +17,7 @@ from mkdocs.tests.base import load_config
class _DummyPluginConfig(base.Config):
foo = c.Type(str, default='default foo')
bar = c.Type(int, default=0)
dir = c.Dir(exists=False)
dir = c.Optional(c.Dir(exists=False))
class DummyPlugin(plugins.BasePlugin[_DummyPluginConfig]):