Source code for rstcheck_core.config

"""Rstcheck configuration functionality."""
import configparser
import contextlib
import enum
import logging
import pathlib
import re
import typing as t

import pydantic

from . import _extras


tomllib_imported = False
try:
    import tomllib

    tomllib_imported = True
except ModuleNotFoundError:
    if _extras.TOMLI_INSTALLED:  # pragma: no cover
        import tomli as tomllib  # type: ignore[import,no-redef]


logger = logging.getLogger(__name__)


CONFIG_FILES = [".rstcheck.cfg", "setup.cfg"]
"""Supported default config files."""
if _extras.TOMLI_INSTALLED or tomllib_imported:  # pragma: no cover
    CONFIG_FILES = [".rstcheck.cfg", "pyproject.toml", "setup.cfg"]


[docs]class ReportLevel(enum.Enum): """Report levels supported by docutils.""" INFO = 1 WARNING = 2 ERROR = 3 SEVERE = 4 NONE = 5
ReportLevelMap = { "info": 1, "warning": 2, "error": 3, "severe": 4, "none": 5, } """Map docutils report levels in text form to numbers.""" DEFAULT_REPORT_LEVEL = ReportLevel.INFO """Default report level.""" def _split_str_validator(value: t.Any) -> t.Optional[t.List[str]]: # noqa: ANN401 """Validate and parse strings and string-lists. Comma separated strings are split into a list. :param value: Value to validate :raises ValueError: If not a :py:class:`str` or :py:class:`list` of :py:class:`str` :return: List of strings """ if value is None: return None if isinstance(value, str): return [v.strip() for v in value.split(",") if v.strip()] if isinstance(value, list) and all(isinstance(v, str) for v in value): return [v.strip() for v in value if v.strip()] raise ValueError("Not a string or list of strings")
[docs]class RstcheckConfigFile(pydantic.BaseModel): # pylint: disable=no-member """Rstcheck config file. :raises ValueError: If setting has incorrect value or type :raises pydantic.error_wrappers.ValidationError: If setting is not parsable into correct type """ report_level: t.Optional[ReportLevel] ignore_directives: t.Optional[t.List[str]] ignore_roles: t.Optional[t.List[str]] ignore_substitutions: t.Optional[t.List[str]] ignore_languages: t.Optional[t.List[str]] # NOTE: Pattern type-arg errors pydanic: https://github.com/samuelcolvin/pydantic/issues/2636 ignore_messages: t.Optional[t.Pattern] # type: ignore[type-arg]
[docs] @pydantic.validator("report_level", pre=True) @classmethod def valid_report_level(cls, value: t.Any) -> t.Optional[ReportLevel]: # noqa: ANN401 """Validate the report_level setting. :param value: Value to validate :raises ValueError: If ``value`` is not a valid docutils report level :return: Instance of :py:class:`ReportLevel` or None if emptry string. """ if value is None: return None if isinstance(value, ReportLevel): return value if value == "": return DEFAULT_REPORT_LEVEL if isinstance(value, bool): raise ValueError("Invalid report level") if isinstance(value, str): if value.casefold() in set(ReportLevelMap): return ReportLevel(ReportLevelMap[value.casefold()]) with contextlib.suppress(ValueError): value = int(value) if isinstance(value, int) and 1 <= value <= 5: return ReportLevel(value) raise ValueError("Invalid report level")
[docs] @pydantic.validator( "ignore_directives", "ignore_roles", "ignore_substitutions", "ignore_languages", pre=True ) @classmethod def split_str(cls, value: t.Any) -> t.Optional[t.List[str]]: # noqa: ANN401 """Validate and parse the following ignore_* settings. - ignore_directives - ignore_roles - ignore_substitutions - ignore_languages Comma separated strings are split into a list. :param value: Value to validate :raises ValueError: If not a :py:class:`str` or :py:class:`list` of :py:class:`str` :return: List of things to ignore in the respective category """ return _split_str_validator(value)
[docs] @pydantic.validator("ignore_messages", pre=True) @classmethod def join_regex_str( cls, value: t.Any # noqa: ANN401 ) -> t.Optional[t.Union[str, t.Pattern[str]]]: """Validate and concatenate the ignore_messages setting to a RegEx string. If a list ist given, the entries are concatenated with "|" to create an or RegEx. :param value: Value to validate :raises ValueError: If not a :py:class:`str` or :py:class:`list` of :py:class:`str` :return: A RegEx string with messages to ignore or :py:class:`typing.Pattern` if it is one already """ if value is None: return None if isinstance(value, re.Pattern): return value if isinstance(value, list) and all(isinstance(v, str) for v in value): return r"|".join(value) if isinstance(value, str): return value raise ValueError("Not a string or list of strings")
[docs]class RstcheckConfig(RstcheckConfigFile): # pylint: disable=too-few-public-methods """Rstcheck config. :raises ValueError: If setting has incorrect value or type :raises pydantic.error_wrappers.ValidationError: If setting is not parsable into correct type """ config_path: t.Optional[pathlib.Path] recursive: t.Optional[bool] warn_unknown_settings: t.Optional[bool]
class _RstcheckConfigINIFile( pydantic.BaseModel # pylint: disable=no-member ): # pylint: disable=too-few-public-methods """Type for [rstcheck] section in INI file. The types apply to the file's data before the parsing by :py:class:`RstcheckConfig` is done. :raises pydantic.error_wrappers.ValidationError: If setting is not parsable into correct type """ report_level: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member ignore_directives: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member ignore_roles: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member ignore_substitutions: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member ignore_languages: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member ignore_messages: pydantic.NoneStr = pydantic.Field(None) # pylint: disable=no-member def _load_config_from_ini_file( ini_file: pathlib.Path, *, log_missing_section_as_warning: bool = True, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Load, parse and validate rstcheck config from a ini file. :param ini_file: INI file to load config from :param log_missing_section_as_warning: If a missing [tool.rstcheck] section should be logged at WARNING (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level; defaults to :py:obj:`True` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :raises FileNotFoundError: If the file is not found :return: instance of :py:class:`RstcheckConfigFile` or :py:class:`None` on missing config section or ``NONE`` is passed as the config path. """ logger.debug(f"Try loading config from INI file: '{ini_file}'") if ini_file.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None resolved_file = ini_file.resolve() if not resolved_file.is_file(): raise FileNotFoundError(f"{resolved_file}") parser = configparser.ConfigParser() parser.read(resolved_file) if not parser.has_section("rstcheck"): if log_missing_section_as_warning: logger.warning(f"Config file has no [rstcheck] section: '{ini_file}'.") return None logger.info(f"Config file has no [rstcheck] section: '{ini_file}'.") return None config_values_raw = dict(parser.items("rstcheck")) if warn_unknown_settings: known_settings = _RstcheckConfigINIFile().dict().keys() unknown = [s for s in config_values_raw.keys() if s not in known_settings] if unknown: logger.warning(f"Unknown setting(s) {unknown} found in file: '{ini_file}'.") config_values_checked = _RstcheckConfigINIFile(**config_values_raw) config_values_parsed = RstcheckConfigFile(**config_values_checked.dict()) return config_values_parsed class _RstcheckConfigTOMLFile( pydantic.BaseModel # pylint: disable=no-member, ): # pylint: disable=too-few-public-methods """Type for [tool.rstcheck] section in TOML file. The types apply to the file's data before the parsing by :py:class:`RstcheckConfig` is done. :raises pydantic.error_wrappers.ValidationError: If setting is not parsable into correct type """ report_level: t.Optional[str] = pydantic.Field(None) ignore_directives: t.Optional[t.List[str]] = pydantic.Field(None) ignore_roles: t.Optional[t.List[str]] = pydantic.Field(None) ignore_substitutions: t.Optional[t.List[str]] = pydantic.Field(None) ignore_languages: t.Optional[t.List[str]] = pydantic.Field(None) ignore_messages: t.Optional[t.Union[str, t.List[str]]] = pydantic.Field(None) def _load_config_from_toml_file( toml_file: pathlib.Path, *, log_missing_section_as_warning: bool = True, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Load, parse and validate rstcheck config from a TOML file. .. warning:: Needs tomli installed for python versions before 3.11! Use toml extra. :param toml_file: TOML file to load config from :param log_missing_section_as_warning: If a missing [tool.rstcheck] section should be logged at WARNING (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level; defaults to :py:obj:`True` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :raises ValueError: If the file is not a TOML file :raises FileNotFoundError: If the file is not found :return: instance of :py:class:`RstcheckConfigFile` or :py:obj:`None` on missing config section or ``NONE`` is passed as the config path. """ _extras.install_guard_tomli(tomllib_imported) logger.debug(f"Try loading config from TOML file: '{toml_file}'.") if toml_file.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None resolved_file = toml_file.resolve() if not resolved_file.is_file(): logging.error(f"Config file is not a file: '{toml_file}'.") raise FileNotFoundError(f"{resolved_file}") if resolved_file.suffix.casefold() != ".toml": logging.error(f"Config file is not a TOML file: '{toml_file}'.") raise ValueError("File is not a TOML file") with open(resolved_file, "rb") as toml_file_handle: toml_dict = tomllib.load(toml_file_handle) optional_rstcheck_section = t.Optional[t.Dict[str, t.Any]] rstcheck_section: optional_rstcheck_section = toml_dict.get("tool", {}).get("rstcheck") if rstcheck_section is None: if log_missing_section_as_warning: logger.warning(f"Config file has no [tool.rstcheck] section: '{toml_file}'.") return None logger.info(f"Config file has no [tool.rstcheck] section: '{toml_file}'.") return None if warn_unknown_settings: known_settings = _RstcheckConfigTOMLFile().dict().keys() unknown = [s for s in rstcheck_section.keys() if s not in known_settings] if unknown: logger.warning(f"Unknown setting(s) {unknown} found in file: '{toml_file}'.") config_values_checked = _RstcheckConfigTOMLFile(**rstcheck_section) config_values_parsed = RstcheckConfigFile(**config_values_checked.dict()) return config_values_parsed
[docs]def load_config_file( file_path: pathlib.Path, *, log_missing_section_as_warning: bool = True, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Load, parse and validate rstcheck config from a file. .. caution:: If a TOML file is passed this function need tomli installed for python versions before 3.11! Use toml extra or install manually. :param file_path: File to load config from :param log_missing_section_as_warning: If a missing config section should be logged at WARNING (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level; defaults to :py:obj:`True` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :raises FileNotFoundError: If the file is not found :return: instance of :py:class:`RstcheckConfigFile` or :py:obj:`None` on missing config section or ``NONE`` is passed as the config path. """ logger.debug("Try loading config file.") if file_path.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None if file_path.suffix.casefold() == ".toml": return _load_config_from_toml_file( file_path, log_missing_section_as_warning=log_missing_section_as_warning, warn_unknown_settings=warn_unknown_settings, ) return _load_config_from_ini_file( file_path, log_missing_section_as_warning=log_missing_section_as_warning, warn_unknown_settings=warn_unknown_settings, )
[docs]def load_config_file_from_dir( dir_path: pathlib.Path, *, log_missing_section_as_warning: bool = False, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Search, load, parse and validate rstcheck config from a directory. Searches files from :py:data:`CONFIG_FILES` in the directory. If a file is found, try to load the config from it. If is has no config, search further. :param dir_path: Directory to search :param log_missing_section_as_warning: If a missing config section in a config file should be logged at WARNING (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level; defaults to :py:obj:`False` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :return: instance of :py:class:`RstcheckConfigFile` or :py:obj:`None` if no file is found or no file has a rstcheck section or ``NONE`` is passed as the config path. """ logger.debug(f"Try loading config file from directory: '{dir_path}'.") if dir_path.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None config = None for file_name in CONFIG_FILES: file_path = (dir_path / file_name).resolve() if file_path.is_file(): config = load_config_file( file_path, log_missing_section_as_warning=( log_missing_section_as_warning or (file_name == ".rstcheck.cfg") ), warn_unknown_settings=warn_unknown_settings, ) if config is not None: break if config is None: logger.info( f"No config section in supported config files found in directory: '{dir_path}'." ) return config
[docs]def load_config_file_from_dir_tree( dir_path: pathlib.Path, *, log_missing_section_as_warning: bool = False, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Search, load, parse and validate rstcheck config from a directory tree. Searches files from :py:data:`CONFIG_FILES` in the directory. If a file is found, try to load the config from it. If is has no config, search further. If no config is found in the directory search its parents one by one. :param dir_path: Directory to search :param log_missing_section_as_warning: If a missing config section in a config file should be logged at ``WARNING`` (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level; defaults to :py:obj:`False` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :return: instance of :py:class:`RstcheckConfigFile` or :py:obj:`None` if no file is found or no file has a rstcheck section or ``NONE`` is passed as the config path. """ logger.debug(f"Try loading config file from directory tree: '{dir_path}'.") if dir_path.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None config = None search_dir = dir_path.resolve() while True: config = load_config_file_from_dir( search_dir, log_missing_section_as_warning=log_missing_section_as_warning, warn_unknown_settings=warn_unknown_settings, ) if config is not None: break parent_dir = search_dir.parent.resolve() if parent_dir == search_dir: break search_dir = parent_dir if config is None: logger.info( f"No config section in supported config files found in directory tree: '{dir_path}'." ) return config
[docs]def load_config_file_from_path( path: pathlib.Path, *, search_dir_tree: bool = False, log_missing_section_as_warning_for_file: bool = True, log_missing_section_as_warning_for_dir: bool = False, warn_unknown_settings: bool = False, ) -> t.Optional[RstcheckConfigFile]: """Analyse the path and call the correct config file loader. :param path: Path to load config file from; can be a file or directory :param search_dir_tree: If the directory tree should be searched; only applies if ``path`` is a directory; defaults to :py:obj:`False` :param log_missing_section_as_warning_for_file: If a missing config section in a config file should be logged at WARNING (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level when the given path is a file; defaults to :py:obj:`True` :param log_missing_section_as_warning_for_dir: If a missing config section in a config file should be logged at ``WARNING`` (:py:obj:`True`) or ``INFO`` (:py:obj:`False`) level when the given file is a direcotry; defaults to :py:obj:`False` :param warn_unknown_settings: If a warning should be logged for unknown settings in config file; defaults to :py:obj:`False` :raises FileNotFoundError: When the passed path is not found. :return: instance of :py:class:`RstcheckConfigFile` or :py:obj:`None` if no file is found or no file has a rstcheck section or ``NONE`` is passed as the config path. """ logger.debug(f"Try loading config file from path: '{path}'.") if path.name == "NONE": logger.info("Config path is set to 'NONE'. No config file is loaded.") return None resolved_path = path.resolve() if resolved_path.is_file(): return load_config_file( resolved_path, log_missing_section_as_warning=log_missing_section_as_warning_for_file, warn_unknown_settings=warn_unknown_settings, ) if resolved_path.is_dir(): if search_dir_tree: return load_config_file_from_dir_tree( resolved_path, log_missing_section_as_warning=log_missing_section_as_warning_for_dir, warn_unknown_settings=warn_unknown_settings, ) return load_config_file_from_dir( resolved_path, log_missing_section_as_warning=log_missing_section_as_warning_for_dir, warn_unknown_settings=warn_unknown_settings, ) raise FileNotFoundError(2, "Passed config path not found.", path)
[docs]def merge_configs( config_base: RstcheckConfig, config_add: t.Union[RstcheckConfig, RstcheckConfigFile], *, config_add_is_dominant: bool = True, ) -> RstcheckConfig: """Merge two configs into a new one. :param config_base: The base config to merge into :param config_add: The config that is merged into the ``config_base`` :param config_add_is_dominant: If the ``config_add`` overwrites values of ``config_base``; defaults to :py:obj:`True` :return: New merged config """ logger.debug("Merging configs.") sub_config: t.Union[RstcheckConfig, RstcheckConfigFile] = config_base sub_config_dict = sub_config.dict() for setting in dict(sub_config_dict): if sub_config_dict[setting] is None: del sub_config_dict[setting] dom_config: t.Union[RstcheckConfig, RstcheckConfigFile] = config_add dom_config_dict = dom_config.dict() for setting in dict(dom_config_dict): if dom_config_dict[setting] is None: del dom_config_dict[setting] if config_add_is_dominant is False: sub_config_dict, dom_config_dict = dom_config_dict, sub_config_dict merged_config_dict = {**sub_config_dict, **dom_config_dict} return RstcheckConfig(**merged_config_dict)