166 lines
5.4 KiB
Python
166 lines
5.4 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import functools
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import os.path
|
||
|
|
from collections.abc import Mapping
|
||
|
|
from typing import IO, TYPE_CHECKING, Any
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
import yaml_env_tag # type: ignore
|
||
|
|
|
||
|
|
from properdocs import exceptions
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from properdocs.config.defaults import ProperDocsConfig
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
def _construct_dir_placeholder(
|
||
|
|
config: ProperDocsConfig, loader: yaml.BaseLoader, node: yaml.ScalarNode
|
||
|
|
) -> _DirPlaceholder:
|
||
|
|
loader.construct_scalar(node)
|
||
|
|
|
||
|
|
value: str = (node and node.value) or ''
|
||
|
|
prefix, _, suffix = value.partition('/')
|
||
|
|
if prefix.startswith('$'):
|
||
|
|
if prefix == '$config_dir':
|
||
|
|
return ConfigDirPlaceholder(config, suffix)
|
||
|
|
elif prefix == '$docs_dir':
|
||
|
|
return DocsDirPlaceholder(config, suffix)
|
||
|
|
else:
|
||
|
|
raise exceptions.ConfigurationError(
|
||
|
|
f"Unknown prefix {prefix!r} in {node.tag} {node.value!r}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return RelativeDirPlaceholder(config, value)
|
||
|
|
|
||
|
|
|
||
|
|
class _DirPlaceholder(os.PathLike):
|
||
|
|
def __init__(self, config: ProperDocsConfig, suffix: str = ''):
|
||
|
|
self.config = config
|
||
|
|
self.suffix = suffix
|
||
|
|
|
||
|
|
def value(self) -> str:
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
def __fspath__(self) -> str:
|
||
|
|
"""Can be used as a path."""
|
||
|
|
return os.path.join(self.value(), self.suffix)
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
"""Can be converted to a string to obtain the current class."""
|
||
|
|
return self.__fspath__()
|
||
|
|
|
||
|
|
|
||
|
|
class ConfigDirPlaceholder(_DirPlaceholder):
|
||
|
|
"""
|
||
|
|
A placeholder object that gets resolved to the directory of the config file when used as a path.
|
||
|
|
|
||
|
|
The suffix can be an additional sub-path that is always appended to this path.
|
||
|
|
|
||
|
|
This is the implementation of the `!relative $config_dir/suffix` tag, but can also be passed programmatically.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def value(self) -> str:
|
||
|
|
return os.path.dirname(self.config.config_file_path)
|
||
|
|
|
||
|
|
|
||
|
|
class DocsDirPlaceholder(_DirPlaceholder):
|
||
|
|
"""
|
||
|
|
A placeholder object that gets resolved to the docs dir when used as a path.
|
||
|
|
|
||
|
|
The suffix can be an additional sub-path that is always appended to this path.
|
||
|
|
|
||
|
|
This is the implementation of the `!relative $docs_dir/suffix` tag, but can also be passed programmatically.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def value(self) -> str:
|
||
|
|
return self.config.docs_dir
|
||
|
|
|
||
|
|
|
||
|
|
class RelativeDirPlaceholder(_DirPlaceholder):
|
||
|
|
"""
|
||
|
|
A placeholder object that gets resolved to the directory of the Markdown file currently being rendered.
|
||
|
|
|
||
|
|
This is the implementation of the `!relative` tag, but can also be passed programmatically.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, config: ProperDocsConfig, suffix: str = ''):
|
||
|
|
if suffix:
|
||
|
|
raise exceptions.ConfigurationError(
|
||
|
|
f"'!relative' tag does not expect any value; received {suffix!r}"
|
||
|
|
)
|
||
|
|
super().__init__(config, suffix)
|
||
|
|
|
||
|
|
def value(self) -> str:
|
||
|
|
current_page = self.config._current_page
|
||
|
|
if current_page is None:
|
||
|
|
raise exceptions.ConfigurationError(
|
||
|
|
"The current file is not set for the '!relative' tag. "
|
||
|
|
"It cannot be used in this context; the intended usage is within `markdown_extensions`."
|
||
|
|
)
|
||
|
|
return os.path.dirname(os.path.join(self.config.docs_dir, current_page.file.src_path))
|
||
|
|
|
||
|
|
|
||
|
|
def get_yaml_loader(loader=yaml.Loader, config: ProperDocsConfig | None = None):
|
||
|
|
"""Wrap PyYaml's loader so we can extend it to suit our needs."""
|
||
|
|
|
||
|
|
class Loader(loader):
|
||
|
|
"""
|
||
|
|
Define a custom loader derived from the global loader to leave the
|
||
|
|
global loader unaltered.
|
||
|
|
"""
|
||
|
|
|
||
|
|
# Attach Environment Variable constructor.
|
||
|
|
# See https://github.com/waylan/pyyaml-env-tag
|
||
|
|
Loader.add_constructor('!ENV', yaml_env_tag.construct_env_tag)
|
||
|
|
|
||
|
|
if config is not None:
|
||
|
|
Loader.add_constructor('!relative', functools.partial(_construct_dir_placeholder, config))
|
||
|
|
|
||
|
|
return Loader
|
||
|
|
|
||
|
|
|
||
|
|
def yaml_load(
|
||
|
|
source: IO | str, loader: type[yaml.BaseLoader | yaml.SafeLoader] | None = None
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Return dict of source YAML file using loader, recursively deep merging inherited parent."""
|
||
|
|
if loader is None:
|
||
|
|
loader = get_yaml_loader()
|
||
|
|
try:
|
||
|
|
result = yaml.load(source, Loader=loader)
|
||
|
|
except yaml.YAMLError as e:
|
||
|
|
raise exceptions.ConfigurationError(
|
||
|
|
f"ProperDocs encountered an error parsing the configuration file: {e}"
|
||
|
|
)
|
||
|
|
if result is None:
|
||
|
|
return {}
|
||
|
|
if (
|
||
|
|
'INHERIT' in result
|
||
|
|
and not isinstance(source, str)
|
||
|
|
and getattr(source, 'name', None) is not None
|
||
|
|
):
|
||
|
|
relpath = result.pop('INHERIT')
|
||
|
|
abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath))
|
||
|
|
if not os.path.exists(abspath):
|
||
|
|
raise exceptions.ConfigurationError(
|
||
|
|
f"Inherited config file '{relpath}' does not exist at '{abspath}'."
|
||
|
|
)
|
||
|
|
log.debug(f"Loading inherited configuration file: {abspath}")
|
||
|
|
with open(abspath, 'rb') as fd:
|
||
|
|
parent = yaml_load(fd, loader)
|
||
|
|
result = deep_merge_dicts(parent, result)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def deep_merge_dicts(dst: dict[str, Any], src: Mapping[str, Any]) -> dict[str, Any]:
|
||
|
|
for key, src_value in src.items():
|
||
|
|
if isinstance(dst.get(key), Mapping) and isinstance(src_value, Mapping):
|
||
|
|
deep_merge_dicts(dst[key], src_value)
|
||
|
|
else:
|
||
|
|
dst[key] = src_value
|
||
|
|
return dst
|