393 lines
13 KiB
Python
393 lines
13 KiB
Python
|
from __future__ import annotations
|
||
|
|
||
|
import functools
|
||
|
import logging
|
||
|
import os
|
||
|
import sys
|
||
|
import warnings
|
||
|
from collections import UserDict
|
||
|
from contextlib import contextmanager
|
||
|
from typing import (
|
||
|
IO,
|
||
|
TYPE_CHECKING,
|
||
|
Any,
|
||
|
Generic,
|
||
|
Iterator,
|
||
|
List,
|
||
|
Mapping,
|
||
|
Sequence,
|
||
|
Tuple,
|
||
|
TypeVar,
|
||
|
overload,
|
||
|
)
|
||
|
|
||
|
from mkdocs import exceptions, utils
|
||
|
from mkdocs.utils import weak_property
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from mkdocs.config.defaults import MkDocsConfig
|
||
|
|
||
|
|
||
|
log = logging.getLogger('mkdocs.config')
|
||
|
|
||
|
|
||
|
T = TypeVar('T')
|
||
|
|
||
|
|
||
|
class BaseConfigOption(Generic[T]):
|
||
|
def __init__(self) -> None:
|
||
|
self.warnings: list[str] = []
|
||
|
self.default = None
|
||
|
|
||
|
@property
|
||
|
def default(self):
|
||
|
try:
|
||
|
# ensure no mutable values are assigned
|
||
|
return self._default.copy()
|
||
|
except AttributeError:
|
||
|
return self._default
|
||
|
|
||
|
@default.setter
|
||
|
def default(self, value):
|
||
|
self._default = value
|
||
|
|
||
|
def validate(self, value: object, /) -> T:
|
||
|
return self.run_validation(value)
|
||
|
|
||
|
def reset_warnings(self) -> None:
|
||
|
self.warnings = []
|
||
|
|
||
|
def pre_validation(self, config: Config, key_name: str) -> None:
|
||
|
"""
|
||
|
Before all options are validated, perform a pre-validation process.
|
||
|
|
||
|
The pre-validation process method should be implemented by subclasses.
|
||
|
"""
|
||
|
|
||
|
def run_validation(self, value: object, /):
|
||
|
"""
|
||
|
Perform validation for a value.
|
||
|
|
||
|
The run_validation method should be implemented by subclasses.
|
||
|
"""
|
||
|
return value
|
||
|
|
||
|
def post_validation(self, config: Config, key_name: str) -> None:
|
||
|
"""
|
||
|
After all options have passed validation, perform a post-validation
|
||
|
process to do any additional changes dependent on other config values.
|
||
|
|
||
|
The post-validation process method should be implemented by subclasses.
|
||
|
"""
|
||
|
|
||
|
def __set_name__(self, owner, name):
|
||
|
if name.endswith('_') and not name.startswith('_'):
|
||
|
name = name[:-1]
|
||
|
self._name = name
|
||
|
|
||
|
@overload
|
||
|
def __get__(self, obj: Config, type=None) -> T:
|
||
|
...
|
||
|
|
||
|
@overload
|
||
|
def __get__(self, obj, type=None) -> BaseConfigOption:
|
||
|
...
|
||
|
|
||
|
def __get__(self, obj, type=None):
|
||
|
if not isinstance(obj, Config):
|
||
|
return self
|
||
|
return obj[self._name]
|
||
|
|
||
|
def __set__(self, obj, value: T):
|
||
|
if not isinstance(obj, Config):
|
||
|
raise AttributeError(
|
||
|
f"can't set attribute ({self._name}) because the parent is a {type(obj)} not a {Config}"
|
||
|
)
|
||
|
obj[self._name] = value
|
||
|
|
||
|
|
||
|
class ValidationError(Exception):
|
||
|
"""Raised during the validation process of the config on errors."""
|
||
|
|
||
|
def __eq__(self, other):
|
||
|
return type(self) is type(other) and str(self) == str(other)
|
||
|
|
||
|
|
||
|
PlainConfigSchemaItem = Tuple[str, BaseConfigOption]
|
||
|
PlainConfigSchema = Sequence[PlainConfigSchemaItem]
|
||
|
|
||
|
ConfigErrors = List[Tuple[str, Exception]]
|
||
|
ConfigWarnings = List[Tuple[str, str]]
|
||
|
|
||
|
|
||
|
class Config(UserDict):
|
||
|
"""
|
||
|
Base class for MkDocs configuration, plugin configuration (and sub-configuration) objects.
|
||
|
|
||
|
It should be subclassed and have `ConfigOption`s defined as attributes.
|
||
|
For examples, see mkdocs/contrib/search/__init__.py and mkdocs/config/defaults.py.
|
||
|
|
||
|
Behavior as it was prior to MkDocs 1.4 is now handled by LegacyConfig.
|
||
|
"""
|
||
|
|
||
|
_schema: PlainConfigSchema
|
||
|
config_file_path: str
|
||
|
|
||
|
def __init_subclass__(cls):
|
||
|
schema = dict(getattr(cls, '_schema', ()))
|
||
|
for attr_name, attr in cls.__dict__.items():
|
||
|
if isinstance(attr, BaseConfigOption):
|
||
|
schema[getattr(attr, '_name', 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:
|
||
|
return LegacyConfig(*args, **kwargs)
|
||
|
return super().__new__(cls)
|
||
|
|
||
|
def __init__(self, config_file_path: str | bytes | None = None):
|
||
|
super().__init__()
|
||
|
self.__user_configs: list[dict] = []
|
||
|
self.set_defaults()
|
||
|
|
||
|
self._schema_keys = {k for k, v in self._schema}
|
||
|
# Ensure config_file_path is a Unicode string
|
||
|
if config_file_path is not None and not isinstance(config_file_path, str):
|
||
|
try:
|
||
|
# Assume config_file_path is encoded with the file system encoding.
|
||
|
config_file_path = config_file_path.decode(encoding=sys.getfilesystemencoding())
|
||
|
except UnicodeDecodeError:
|
||
|
raise ValidationError("config_file_path is not a Unicode string.")
|
||
|
self.config_file_path = config_file_path or ''
|
||
|
|
||
|
def set_defaults(self) -> None:
|
||
|
"""
|
||
|
Set the base config by going through each validator and getting the
|
||
|
default if it has one.
|
||
|
"""
|
||
|
for key, config_option in self._schema:
|
||
|
self[key] = config_option.default
|
||
|
|
||
|
def _validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
|
||
|
failed: ConfigErrors = []
|
||
|
warnings: ConfigWarnings = []
|
||
|
|
||
|
for key, config_option in self._schema:
|
||
|
try:
|
||
|
value = self.get(key)
|
||
|
self[key] = config_option.validate(value)
|
||
|
warnings.extend((key, w) for w in config_option.warnings)
|
||
|
config_option.reset_warnings()
|
||
|
except ValidationError as e:
|
||
|
failed.append((key, e))
|
||
|
break
|
||
|
|
||
|
for key in set(self.keys()) - self._schema_keys:
|
||
|
warnings.append((key, f"Unrecognised configuration name: {key}"))
|
||
|
|
||
|
return failed, warnings
|
||
|
|
||
|
def _pre_validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
|
||
|
failed: ConfigErrors = []
|
||
|
warnings: ConfigWarnings = []
|
||
|
|
||
|
for key, config_option in self._schema:
|
||
|
try:
|
||
|
config_option.pre_validation(self, key_name=key)
|
||
|
warnings.extend((key, w) for w in config_option.warnings)
|
||
|
config_option.reset_warnings()
|
||
|
except ValidationError as e:
|
||
|
failed.append((key, e))
|
||
|
|
||
|
return failed, warnings
|
||
|
|
||
|
def _post_validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
|
||
|
failed: ConfigErrors = []
|
||
|
warnings: ConfigWarnings = []
|
||
|
|
||
|
for key, config_option in self._schema:
|
||
|
try:
|
||
|
config_option.post_validation(self, key_name=key)
|
||
|
warnings.extend((key, w) for w in config_option.warnings)
|
||
|
config_option.reset_warnings()
|
||
|
except ValidationError as e:
|
||
|
failed.append((key, e))
|
||
|
|
||
|
return failed, warnings
|
||
|
|
||
|
def validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
|
||
|
failed, warnings = self._pre_validate()
|
||
|
|
||
|
run_failed, run_warnings = self._validate()
|
||
|
|
||
|
failed.extend(run_failed)
|
||
|
warnings.extend(run_warnings)
|
||
|
|
||
|
# Only run the post validation steps if there are no failures, warnings
|
||
|
# are okay.
|
||
|
if len(failed) == 0:
|
||
|
post_failed, post_warnings = self._post_validate()
|
||
|
failed.extend(post_failed)
|
||
|
warnings.extend(post_warnings)
|
||
|
|
||
|
return failed, warnings
|
||
|
|
||
|
def load_dict(self, patch: dict) -> None:
|
||
|
"""Load config options from a dictionary."""
|
||
|
if not isinstance(patch, dict):
|
||
|
raise exceptions.ConfigurationError(
|
||
|
"The configuration is invalid. Expected a key-"
|
||
|
f"value mapping (dict) but received: {type(patch)}"
|
||
|
)
|
||
|
|
||
|
self.__user_configs.append(patch)
|
||
|
self.update(patch)
|
||
|
|
||
|
def load_file(self, config_file: IO) -> None:
|
||
|
"""Load config options from the open file descriptor of a YAML file."""
|
||
|
warnings.warn(
|
||
|
"Config.load_file is not used since MkDocs 1.5 and will be removed soon. "
|
||
|
"Use MkDocsConfig.load_file instead",
|
||
|
DeprecationWarning,
|
||
|
)
|
||
|
return self.load_dict(utils.yaml_load(config_file))
|
||
|
|
||
|
@weak_property
|
||
|
def user_configs(self) -> Sequence[Mapping[str, Any]]:
|
||
|
warnings.warn(
|
||
|
"user_configs is never used in MkDocs and will be removed soon.", DeprecationWarning
|
||
|
)
|
||
|
return self.__user_configs
|
||
|
|
||
|
|
||
|
@functools.lru_cache(maxsize=None)
|
||
|
def get_schema(cls: type) -> PlainConfigSchema:
|
||
|
"""Extract ConfigOptions defined in a class (used just as a container) and put them into a schema tuple."""
|
||
|
if issubclass(cls, Config):
|
||
|
return cls._schema
|
||
|
return tuple((k, v) for k, v in cls.__dict__.items() if isinstance(v, BaseConfigOption))
|
||
|
|
||
|
|
||
|
class LegacyConfig(Config):
|
||
|
"""A configuration object for plugins, as just a dict without type-safe attribute access."""
|
||
|
|
||
|
def __init__(self, schema: PlainConfigSchema, config_file_path: str | None = None):
|
||
|
self._schema = tuple((k, v) for k, v in schema) # Re-create just for validation
|
||
|
super().__init__(config_file_path)
|
||
|
|
||
|
|
||
|
@contextmanager
|
||
|
def _open_config_file(config_file: str | IO | None) -> Iterator[IO]:
|
||
|
"""
|
||
|
A context manager which yields an open file descriptor ready to be read.
|
||
|
|
||
|
Accepts a filename as a string, an open or closed file descriptor, or None.
|
||
|
When None, it defaults to `mkdocs.yml` in the CWD. If a closed file descriptor
|
||
|
is received, a new file descriptor is opened for the same file.
|
||
|
|
||
|
The file descriptor is automatically closed when the context manager block is existed.
|
||
|
"""
|
||
|
# Default to the standard config filename.
|
||
|
if config_file is None:
|
||
|
paths_to_try = ['mkdocs.yml', 'mkdocs.yaml']
|
||
|
# If it is a string, we can assume it is a path and attempt to open it.
|
||
|
elif isinstance(config_file, str):
|
||
|
paths_to_try = [config_file]
|
||
|
# If closed file descriptor, get file path to reopen later.
|
||
|
elif getattr(config_file, 'closed', False):
|
||
|
paths_to_try = [config_file.name]
|
||
|
else:
|
||
|
result_config_file = config_file
|
||
|
paths_to_try = None
|
||
|
|
||
|
if paths_to_try:
|
||
|
# config_file is not a file descriptor, so open it as a path.
|
||
|
for path in paths_to_try:
|
||
|
path = os.path.abspath(path)
|
||
|
log.debug(f"Loading configuration file: {path}")
|
||
|
try:
|
||
|
result_config_file = open(path, 'rb')
|
||
|
break
|
||
|
except FileNotFoundError:
|
||
|
continue
|
||
|
else:
|
||
|
raise exceptions.ConfigurationError(f"Config file '{paths_to_try[0]}' does not exist.")
|
||
|
else:
|
||
|
log.debug(f"Loading configuration file: {result_config_file}")
|
||
|
# Ensure file descriptor is at beginning
|
||
|
try:
|
||
|
result_config_file.seek(0)
|
||
|
except OSError:
|
||
|
pass
|
||
|
|
||
|
try:
|
||
|
yield result_config_file
|
||
|
finally:
|
||
|
if hasattr(result_config_file, 'close'):
|
||
|
result_config_file.close()
|
||
|
|
||
|
|
||
|
def load_config(
|
||
|
config_file: str | IO | None = None, *, config_file_path: str | None = None, **kwargs
|
||
|
) -> MkDocsConfig:
|
||
|
"""
|
||
|
Load the configuration for a given file object or name.
|
||
|
|
||
|
The config_file can either be a file object, string or None. If it is None
|
||
|
the default `mkdocs.yml` filename will loaded.
|
||
|
|
||
|
Extra kwargs are passed to the configuration to replace any default values
|
||
|
unless they themselves are None.
|
||
|
"""
|
||
|
options = kwargs.copy()
|
||
|
|
||
|
# Filter None values from the options. This usually happens with optional
|
||
|
# parameters from Click.
|
||
|
for key, value in options.copy().items():
|
||
|
if value is None:
|
||
|
options.pop(key)
|
||
|
|
||
|
with _open_config_file(config_file) as fd:
|
||
|
# Initialize the config with the default schema.
|
||
|
from mkdocs.config.defaults import MkDocsConfig
|
||
|
|
||
|
if config_file_path is None:
|
||
|
if sys.stdin and fd is not sys.stdin.buffer:
|
||
|
config_file_path = getattr(fd, 'name', None)
|
||
|
cfg = MkDocsConfig(config_file_path=config_file_path)
|
||
|
# load the config file
|
||
|
cfg.load_file(fd)
|
||
|
|
||
|
# Then load the options to overwrite anything in the config.
|
||
|
cfg.load_dict(options)
|
||
|
|
||
|
errors, warnings = cfg.validate()
|
||
|
|
||
|
for config_name, warning in warnings:
|
||
|
log.warning(f"Config value '{config_name}': {warning}")
|
||
|
|
||
|
for config_name, error in errors:
|
||
|
log.error(f"Config value '{config_name}': {error}")
|
||
|
|
||
|
for key, value in cfg.items():
|
||
|
log.debug(f"Config value '{key}' = {value!r}")
|
||
|
|
||
|
if len(errors) > 0:
|
||
|
raise exceptions.Abort("Aborted with a configuration error!")
|
||
|
elif cfg.strict and len(warnings) > 0:
|
||
|
raise exceptions.Abort(
|
||
|
f"Aborted with {len(warnings)} configuration warnings in 'strict' mode!"
|
||
|
)
|
||
|
|
||
|
return cfg
|