pyapp-org/pyapp

View on GitHub
src/pyapp/documentation/settings.py

Summary

Maintainability
A
2 hrs
Test Coverage
"""Automated settings documentation."""
import ast
from functools import singledispatchmethod
from pathlib import Path
from types import ModuleType
from typing import (
    Any,
    Dict,
    List,
    NamedTuple,
    Optional,
    Sequence,
    Tuple,
    Union,
)


class _SettingsExtractor:
    """Used to extract documentation from a default settings modules."""

    def __init__(self, module_or_file: Union[ModuleType, Path, str]):
        if isinstance(module_or_file, ModuleType):
            module_or_file = module_or_file.__file__
        self._file = Path(module_or_file)

        self._current_setting: Optional[Tuple[str, Optional[str], Optional[str]]] = None

    def process(self):
        """Process the settings module or file."""

        content = self._file.read_text(encoding="UTF-8")
        mod = ast.parse(content, self._file)
        self._process_node(mod)

        return self

    def setting(
        self,
        setting_key: str,
        type_name: Optional[str],
        default: Any,
        doc: Optional[str],
    ):
        """Event when a setting is found.

        :param setting_key: Name of the setting key.
        :param type_name: Optional type of the type.
        :param default: Optional default value.
        :param doc: Optional doc string for setting.
        """

    def start_settings_def(self, name: str, doc: Optional[str] = None):
        """Start of settings definition.

        :param name: Name of the settings definition group.
        :param doc: Optional doc string for the settings definition.
        """

    def end_settings_def(self):
        """End of settings definition."""

    def _generate_setting(self, doc: Optional[str] = None):
        """Generate a setting if one is defined."""
        if self._current_setting:
            self.setting(*self._current_setting, doc)
            self._current_setting = None

    @singledispatchmethod
    def _process_node(self, node):
        """Process a node from the settings file."""
        self._generate_setting()

    @_process_node.register
    def _process_module(self, node: ast.Module):
        """Process a module."""
        for item in node.body:
            self._process_node(item)

        # Ensure the last setting is generated
        self._generate_setting()

    @_process_node.register
    def _process_class_definition(self, node: ast.ClassDef):
        """Process a class definition."""

        self._generate_setting()

        if any(
            base.id == "SettingsDef"
            for base in node.bases
            if isinstance(base, ast.Name)
        ):
            self.start_settings_def(node.name, ast.get_docstring(node))
            for item in node.body:
                self._process_node(item)

            self._generate_setting()
            self.end_settings_def()

    @_process_node.register
    def _process_assign(self, node: ast.Assign):
        """Process an assignment."""

        self._generate_setting()

        for target in node.targets:
            if isinstance(target, ast.Name) and target.id.isupper():
                self._current_setting = (
                    target.id,
                    None,
                    flatten_default_value(node.value),
                )
                break

    @_process_node.register
    def _process_annotated_assign(self, node: ast.AnnAssign):
        """Process an annotated assignment."""

        self._generate_setting()

        if isinstance(node.target, ast.Name) and node.target.id.isupper():
            self._current_setting = (
                node.target.id,
                flatten_type_annotation(node.annotation),
                flatten_default_value(node.value),
            )

    @_process_node.register
    def _process_expr(self, node: ast.Expr):
        """Process an expression."""
        if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
            self._generate_setting(node.value.value)
        else:
            self._generate_setting()


def flatten_type_annotation(annotation) -> Optional[str]:
    """Flatten a type annotation to a string."""
    if isinstance(annotation, ast.Name):
        return annotation.id

    if isinstance(annotation, ast.Subscript):
        if isinstance(annotation.value, ast.Name) and annotation.value.id == "Union":
            # Compatibility with Python 3.8
            slice_value = (
                annotation.slice.value
                if isinstance(annotation.slice, ast.Index)
                else annotation.slice
            )
            if isinstance(slice_value, ast.Tuple):
                return " | ".join(flatten_type_annotation(v) for v in slice_value.elts)
        return f"{flatten_type_annotation(annotation.value)}"

    return None


def flatten_default_value(value) -> Union[str, int, float, bool, list, dict, None]:
    """Flatten a default value to a string."""

    if isinstance(value, ast.Constant):
        return value.value

    if isinstance(value, ast.Name):
        return value.id

    if isinstance(value, ast.Dict):
        return {k.s: flatten_default_value(v) for k, v in zip(value.keys, value.values)}

    if isinstance(value, (ast.List, ast.Tuple)):
        return [flatten_default_value(v) for v in value.elts]

    return None


class SettingDef(NamedTuple):
    """A setting definition."""

    key: str
    type_name: Optional[str]
    default: Any
    doc: Optional[str]


class SettingDefGroup(NamedTuple):
    """A group of setting definitions."""

    name: Optional[str]
    settings: List[SettingDef]
    doc: Optional[str]

    @property
    def sorted_settings(self) -> List[SettingDef]:
        """Sort settings by key."""
        return sorted(self.settings, key=lambda s: s.key)


class SettingsCollection(_SettingsExtractor):
    """Collect settings from a settings module."""

    def __init__(
        self,
        module_or_file: Union[ModuleType, Path, str],
        *,
        exclude_keys: Sequence[str] = ("INCLUDE_SETTINGS",),
    ):
        super().__init__(module_or_file)
        self.exclude_keys = exclude_keys

        self.current_group: SettingDefGroup = SettingDefGroup(None, [], None)
        self.settings: Dict[Optional[str], SettingDefGroup] = {
            None: self.current_group,
        }

    @property
    def all_settings(self) -> SettingDefGroup:
        """Return all settings ignoring groups."""
        return SettingDefGroup(
            None,
            [setting for group in self.settings.values() for setting in group.settings],
            None,
        )

    def setting(
        self,
        setting_key: str,
        type_name: Optional[str],
        default: Any,
        doc: Optional[str],
    ):
        """Event when a setting is found.

        :param setting_key: Name of the setting key.
        :param type_name: Optional type of the type.
        :param default: Optional default value.
        :param doc: Optional doc string for setting.
        """
        if setting_key not in self.exclude_keys:
            self.current_group.settings.append(
                SettingDef(setting_key, type_name, default, doc)
            )

    def start_settings_def(self, name: str, doc: Optional[str] = None):
        """Start of settings definition.

        :param name: Name of the settings definition group.
        :param doc: Optional doc string for the settings definition.
        """
        self.current_group = _current_group = SettingDefGroup(name, [], doc)
        self.settings[name] = _current_group

    def end_settings_def(self):
        """End of settings definition."""
        self.current_group = self.settings[None]