Source code for project_config.config

"""Configuration handler."""

from __future__ import annotations

import importlib
import os
import re
import typing as t

from project_config.cache import Cache
from project_config.compat import TypeAlias, tomllib_package_name
from project_config.config.exceptions import (
    ConfigurationFilesNotFound,
    CustomConfigFileNotFound,
    ProjectConfigAlreadyInitialized,
    ProjectConfigInvalidConfig,
    ProjectConfigInvalidConfigSchema,
    PyprojectTomlFoundButHasNoConfig,
)
from project_config.config.style import Style
from project_config.fetchers import fetch
from project_config.reporters import DEFAULT_REPORTER, POSSIBLE_REPORTER_IDS


CONFIG_CACHE_REGEX = (
    r"^(\d+ ((seconds?)|(minutes?)|(hours?)|(days?)|(weeks?)))|(never)$"
)

ConfigType: TypeAlias = t.Dict[str, t.Union[str, t.List[str]]]


[docs]def read_config_from_pyproject_toml( filepath: str = "pyproject.toml", ) -> t.Optional[t.Any]: """Read the configuration from the `pyproject.toml` file. Returns: object: ``None`` if not found, configuration data otherwise. """ tomllib = importlib.import_module(tomllib_package_name) with open(filepath, "rb") as f: pyproject_toml = tomllib.load(f) if "tool" in pyproject_toml and "project-config" in pyproject_toml["tool"]: return pyproject_toml["tool"]["project-config"] return None
[docs]def read_config( custom_file_path: t.Optional[str] = None, ) -> t.Tuple[str, t.Any]: """Read the configuration from a file. Args: custom_file_path (str): Custom configuration file path or ``None`` if the configuration must be read from one of the default configuration file paths. Returns: object: Configuration data. """ if custom_file_path: if not os.path.isfile(custom_file_path): raise CustomConfigFileNotFound(custom_file_path) return custom_file_path, dict(fetch(custom_file_path)) pyproject_toml_exists = os.path.isfile("pyproject.toml") config = None if pyproject_toml_exists: config = read_config_from_pyproject_toml() if config is not None: return '"pyproject.toml".[tool.project-config]', dict(config) project_config_toml_exists = os.path.isfile(".project-config.toml") if project_config_toml_exists: tomllib = importlib.import_module(tomllib_package_name) with open(".project-config.toml", "rb") as f: project_config_toml = tomllib.load(f) return ".project-config.toml", project_config_toml if pyproject_toml_exists: raise PyprojectTomlFoundButHasNoConfig() raise ConfigurationFilesNotFound()
[docs]def validate_config_style(config: t.Any) -> t.List[str]: """Validate the ``style`` field of a configuration object. Args: config (object): Configuration data to validate. Returns: list: Found error messages. """ error_messages = [] if "style" not in config: error_messages.append("style -> at least one is required") elif not isinstance(config["style"], (str, list)): error_messages.append("style -> must be of type string or array") elif isinstance(config["style"], list): if not config["style"]: error_messages.append("style -> at least one is required") else: for i, style in enumerate(config["style"]): if not isinstance(style, str): error_messages.append( f"style[{i}] -> must be of type string", ) elif not style: error_messages.append(f"style[{i}] -> must not be empty") elif not config["style"]: error_messages.append("style -> must not be empty") return error_messages
[docs]def _cache_string_to_seconds(cache_string: str) -> int: if "never" in cache_string: return 0 cache_number = int(cache_string.split(" ", maxsplit=1)[0]) if "minute" in cache_string: return cache_number * 60 elif "hour" in cache_string: return cache_number * 60 * 60 elif "day" in cache_string: return cache_number * 60 * 60 * 24 elif "second" in cache_string: return cache_number elif "week" in cache_string: return cache_number * 60 * 60 * 24 * 7 raise ValueError(cache_string)
[docs]def validate_config_cache(config: t.Any) -> t.List[str]: """Validate the ``cache`` field of a configuration object. Args: config (object): Configuration data to validate. Returns: list: Found error messages. """ error_messages = [] if "cache" in config: if not isinstance(config["cache"], str): error_messages.append("cache -> must be of type string") elif not config["cache"]: error_messages.append("cache -> must not be empty") elif not re.match(CONFIG_CACHE_REGEX, config["cache"]): error_messages.append( f"cache -> must match the regex {CONFIG_CACHE_REGEX}", ) else: # 5 minutes as default cache config["cache"] = "5 minutes" return error_messages
[docs]def validate_config(config_path: str, config: t.Any) -> None: """Validate a configuration. Args: config_path (str): Configuration file path. config (object): Configuration data to validate. """ error_messages = [ *validate_config_style(config), *validate_config_cache(config), ] if error_messages: raise ProjectConfigInvalidConfigSchema( config_path, error_messages, )
[docs]def _validate_cli_config(config: t.Dict[str, t.Any]) -> t.List[str]: errors: t.List[str] = [] if "reporter" in config: if not isinstance(config["reporter"], str): errors.append("cli.reporter -> must be of type string") elif not config["reporter"]: errors.append("cli.reporter -> must not be empty") elif config["reporter"] not in POSSIBLE_REPORTER_IDS: errors.append( "cli.reporter -> must be one of the available reporters", ) if "color" in config: if not isinstance(config["color"], bool): errors.append("cli.color -> must be of type boolean") if "colors" in config: if not isinstance(config["colors"], dict): errors.append("cli.colors -> must be of type object") elif not config["colors"]: errors.append("cli.colors -> must not be empty") # colors are validated in the reporter if "rootdir" in config: if not isinstance(config["rootdir"], str): errors.append("cli.rootdir -> must be of type string") elif not config["rootdir"]: errors.append("cli.rootdir -> must not be empty") else: config["rootdir"] = os.path.abspath( os.path.expanduser(config["rootdir"]), ) return errors
[docs]def validate_cli_config( config_path: str, config: t.Dict[str, t.Any], ) -> t.Dict[str, t.Any]: """Validates the CLI configuration. Args: config (dict): Raw CLI configuration. Returns: object: CLI configuration data. """ errors = _validate_cli_config(config) if errors: raise ProjectConfigInvalidConfigSchema(config_path, errors) return config
[docs]class Config: """Configuration wrapper. Args: rootdir (str): Project root directory. path (str): Path to the file from which the configuration will be loaded. fetch_styles (bool): Whether to fetch the styles in the styles loader. validate_cli_config (bool): Whether to validate the CLI configuration. """ def __init__( self, rootdir: str, path: t.Optional[str], ) -> None: self.rootdir = rootdir self.path, config = read_config(path) validate_config(self.path, config) # cli configuration in file self.cli = validate_cli_config(self.path, config.pop("cli", {})) config["_cache"] = config["cache"] config["cache"] = _cache_string_to_seconds(config["cache"]) # set the cache expiration time globally Cache.set_expiration_time(config["cache"]) # main configuration in file self.dict_: ConfigType = config
[docs] def load_style(self) -> None: # noqa: D102 self.style = Style.from_config(self)
[docs] def guess_from_cli_arguments( self, color: t.Optional[bool], reporter: t.Dict[str, t.Any], rootdir: str, ) -> t.Tuple[t.Any, t.Dict[str, t.Any], t.Any, bool]: """Guess the final configuration merging file with CLI arguments.""" # colorize output? color = self.cli.get("color") if color is True else color # reporter definition reporter_kwargs = reporter.get("kwargs", {}) reporter_id = reporter.get( "name", self.cli.get("reporter", DEFAULT_REPORTER), ) if ":" in reporter_id: reporter["name"], reporter_kwargs["fmt"] = reporter_id.split( ":", maxsplit=1, ) else: reporter["name"] = reporter_id if color in (True, None): if "colors" in self.cli: colors = self.cli.get("colors", {}) for key, value in reporter_kwargs.get("colors", {}).items(): colors[key] = value # cli overrides config reporter_kwargs["colors"] = colors reporter["kwargs"] = reporter_kwargs else: reporter["kwargs"] = reporter_kwargs if not rootdir: rootdir = self.cli.get("rootdir") # type: ignore if rootdir: self.rootdir = os.path.expanduser(rootdir) else: self.rootdir = os.getcwd() else: self.rootdir = os.path.abspath(rootdir) if not os.path.isdir(self.rootdir): # pragma: no cover raise ProjectConfigInvalidConfig( f"Root directory '{rootdir}' must be an existing directory", ) only_hints = self.cli.get("only_hints") is True return ( color, reporter, self.rootdir, only_hints, )
def __getitem__(self, key: str) -> t.Any: return self.dict_.__getitem__(key) def __setitem__(self, key: str, value: t.Any) -> None: self.dict_.__setitem__(key, value)
[docs]def initialize_config(config_filepath: str) -> str: """Initialize the configuration. Args: config_filepath (str): Path to the file in which the configuration will be stored. Returns: str: Path to the configuration. """ config_dirpath = os.path.join( os.path.abspath(os.path.dirname(config_filepath)), ) os.makedirs(config_dirpath, exist_ok=True) if not os.path.isfile(config_filepath): with open(config_filepath, "w") as f: f.write("") style_filepath = os.path.join(config_dirpath, "style.json5") config_file_basename = os.path.basename(config_filepath) def create_default_style_file( config_prefix: str = "@", prefix_jmespaths: str = "", ) -> None: """Create the default style file if it does not exist.""" if config_prefix == "@": def key_matcher(key: str) -> str: return key style_setter = "set(@, 'style', ['style.json5'])" cache_setter = "set(@, 'cache', '5 minutes')" else: def key_matcher(key: str) -> str: return f'tool.\\"project-config\\".{key}' style_setter = ( "set(@, 'tool', set(tool, 'project-config'," ' set(tool.\\"project-config\\",' " 'style', ['style.json5'])))" ) cache_setter = ( "set(@, 'tool', set(tool, 'project-config'," ' set(tool.\\"project-config\\",' " 'cache', '5 minutes')))" ) with open(style_filepath, "w") as f: f.write( '''{ rules: [ { files: ["''' + config_file_basename + """"], JMESPathsMatch: [\n """ + prefix_jmespaths + """["type(""" + key_matcher("style") + """)", "array"], ["op(length(""" + key_matcher("style") + """), '>', `0`)", true, """ + '"' + style_setter + '"' + """], ["type(""" + key_matcher("cache") + """)", "string", """ + '"' + cache_setter + '"' + """], [ "regex_match('(\\\\d+ ((seconds?)|(minutes?)|(hours?)|(days?)|(weeks?)))|(never)$', """ # noqa: E501, + key_matcher("cache") + """)", true, "5 minutes", ], ] } ] } """, ) def build_config_string(pyproject_toml: bool = False) -> str: result = "" if pyproject_toml: result += "[tool.project-config]\n" return f'{result}style = ["style.json5"]\ncache = "5 minutes"\n' def add_config_string_to_file(string: str) -> None: with open(config_filepath, encoding="utf-8") as f: config_lines = f.read().splitlines() if config_lines: add_separator = config_lines[-1] != "" else: add_separator = False with open(config_filepath, "a") as f: if add_separator: f.write("\n") f.write(string) if config_file_basename == "pyproject.toml": config = read_config_from_pyproject_toml(config_filepath) if config: raise ProjectConfigAlreadyInitialized( f"{config_filepath}[tool.project-config]", ) add_config_string_to_file(build_config_string(pyproject_toml=True)) create_default_style_file( config_prefix='tool.\\"project-config\\"', prefix_jmespaths=( '["type(tool)", "object"],' '\n ["type(tool.\\"project-config\\")", "object"],' "\n " ), ) return f"{config_filepath}[tool.project-config]" if os.path.isfile(config_filepath): with open(config_filepath, encoding="utf-8") as f: if f.read(): raise ProjectConfigAlreadyInitialized(config_filepath) add_config_string_to_file(build_config_string()) create_default_style_file() return config_filepath