diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py index d1510aff..81da500f 100644 --- a/mkdocs/config/base.py +++ b/mkdocs/config/base.py @@ -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: diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 684244d9..53d18a14 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -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]]): diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index f79cfa86..2a731795 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -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 diff --git a/mkdocs/contrib/search/__init__.py b/mkdocs/contrib/search/__init__.py index 59bb0776..eb8c705c 100644 --- a/mkdocs/contrib/search/__init__.py +++ b/mkdocs/contrib/search/__init__.py @@ -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) diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py index 6c34817f..29a13546 100644 --- a/mkdocs/tests/plugin_tests.py +++ b/mkdocs/tests/plugin_tests.py @@ -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]):