Source code for project_config.config

"""Configuration handler."""

from __future__ import annotations

import argparse
import os
import re
from typing import TYPE_CHECKING, Any

from project_config import tree
from project_config.cache import Cache
from project_config.config.exceptions import (
    ConfigurationFilesNotFound,
    CustomConfigFileNotFound,
    ProjectConfigAlreadyInitialized,
    ProjectConfigInvalidConfig,
    ProjectConfigInvalidConfigSchema,
    PyprojectTomlFoundButHasNoConfig,
)
from project_config.config.style import Style
from project_config.reporters import DEFAULT_REPORTER, get_reporter, reporters


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

if TYPE_CHECKING:
    from project_config.compat import NotRequired, TypedDict
    from project_config.config.style import StyleType

    class CLIConfigType(TypedDict):  # noqa: D101
        rootdir: str
        reporter: str
        color: bool
        colors: dict[str, str]
        only_hints: bool
        _reporter_definition: dict[str, Any]

    class BaseConfigType(TypedDict):  # noqa: D101
        style: NotRequired[StyleType]
        cli: CLIConfigType

    class RawConfigType(BaseConfigType):  # noqa: D101
        cache: str

    class ConfigType(BaseConfigType):  # noqa: D101
        cache: int


[docs]def read_config_from_pyproject_toml(fpath: str) -> Any: """Read the configuration from the `pyproject.toml` file. Returns: object: ``None`` if not found, configuration data otherwise. """ tree.cache_file(fpath) pyproject_toml = tree.cached_local_file(fpath, serializer="toml") if "tool" in pyproject_toml and "project-config" in pyproject_toml["tool"]: return pyproject_toml["tool"]["project-config"] return None
[docs]def read_config( rootdir: str, custom_fpath: str | None = None, ) -> tuple[str, Any]: """Read the configuration from a file. Args: rootdir (str): Project root directory. custom_fpath (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_fpath: if not os.path.isfile(custom_fpath): raise CustomConfigFileNotFound( os.path.relpath(custom_fpath, rootdir), ) tree.cache_file(custom_fpath) return custom_fpath, tree.cached_local_file( custom_fpath, serializer="toml", ) pyproject_toml_path = os.path.join(rootdir, "pyproject.toml") pyproject_toml_exists = os.path.isfile(pyproject_toml_path) config = None if pyproject_toml_exists: config = read_config_from_pyproject_toml(pyproject_toml_path) if config is not None: return '"pyproject.toml".[tool.project-config]', config project_config_toml_path = os.path.join(rootdir, ".project-config.toml") project_config_toml_exists = os.path.isfile(project_config_toml_path) if project_config_toml_exists: tree.cache_file(".project-config.toml") return ".project-config.toml", tree.cached_local_file( ".project-config.toml", serializer="toml", ) if pyproject_toml_exists: raise PyprojectTomlFoundButHasNoConfig() raise ConfigurationFilesNotFound()
[docs]def validate_config_style(config: Any) -> list[str]: # noqa: PLR0912 """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") else: fpath = os.path.join( os.path.dirname(config["_path"]), style, ) if os.path.isfile(fpath): config["style"][i] = fpath elif not config["style"]: error_messages.append("style -> must not be empty") else: fpath = os.path.join(os.path.dirname(config["_path"]), config["style"]) if os.path.isfile(fpath): config["style"] = fpath return error_messages
[docs]def _cache_string_to_seconds(cache_string: str) -> int: if "never" in cache_string: # The cache is not really disabled internally because # is needed for project-config to run. Just use a very # short time to "expire it" after the execution return 1 # 1 second cache_number = int(cache_string.split(" ", maxsplit=1)[0]) if "minute" in cache_string: return cache_number * 60 if "hour" in cache_string: return cache_number * 60 * 60 if "day" in cache_string: return cache_number * 60 * 60 * 24 if "second" in cache_string: return cache_number if "week" in cache_string: return cache_number * 60 * 60 * 24 * 7 raise ValueError(cache_string)
[docs]def validate_config_cache(config: Any) -> 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: 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: dict[str, Any]) -> list[str]: errors: 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 reporters: errors.append( "cli.reporter -> must be one of the available reporters", ) if "color" in config and 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: dict[str, Any], ) -> dict[str, Any]: """Validates the CLI configuration. Args: config_path (str): Configuration file path. 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 FileConfig: """File configuration wrapper. Stores the data of the project-config configuration defined in the file '.project-config.toml' or equivalent. The configuration of the file is stored in the ``dict_`` attribute. """ def __init__( self, rootdir: str, path: str | None, store_raw_config: bool = False, # noqa: FBT001, FBT002 ) -> None: """File configuration wrapper initializer. Args: rootdir (str): Project root directory. path (str): Path to the file from which the configuration will be loaded. store_raw_config (bool): If ``True``, the raw configuration of the file will be stored in the ``raw_`` attribute. """ Cache.ensure_dir() self.path, config = read_config(rootdir, path) if store_raw_config: import copy self.raw_: RawConfigType = copy.deepcopy(config) # Temporally store configuration path to use in validation config["_path"] = self.path validate_config(self.path, config) config["cache"] = _cache_string_to_seconds(config["cache"]) # cli configuration in file config["cli"] = validate_cli_config(self.path, config.get("cli", {})) del config["_path"] # set the cache expiration time globally Cache.set_expiration_time(config["cache"]) # configuration self.dict_: ConfigType = config
[docs] def load_style(self) -> None: # noqa: D102 self.style = Style.from_config(self)
[docs]class Config(FileConfig): """Main configuration wrapper. This class is the main configuration wrapper. It is used to load a configuration object from CLI arguments. """ def __init__(self, args: argparse.Namespace, **kwargs: Any) -> None: """Guess the final configuration merging file with CLI arguments.""" super().__init__(args.rootdir or os.getcwd(), args.config, **kwargs) # Disable cache from CLI if ( os.environ.get("PROJECT_CONFIG_USE_CACHE") == "false" or args.cache is False ) and self.dict_["cache"] < 1: self.dict_["cache"] = 1 # colorize output? self.dict_["cli"]["color"] = ( self.dict_["cli"].get("color") if args.color is True else args.color ) # reporter definition reporter_kwargs = args.reporter.get("kwargs", {}) reporter_id = args.reporter.get( "name", self.dict_["cli"].get("reporter", DEFAULT_REPORTER), ) if ":" in reporter_id: args.reporter["name"], reporter_kwargs["fmt"] = reporter_id.split( ":", maxsplit=1, ) else: args.reporter["name"] = reporter_id if args.color in (True, None): if "colors" in self.dict_["cli"]: colors = self.dict_["cli"].get("colors", {}) for key, value in reporter_kwargs.get("colors", {}).items(): colors[key] = value # cli overrides config reporter_kwargs["colors"] = colors args.reporter["kwargs"] = reporter_kwargs else: args.reporter["kwargs"] = reporter_kwargs self.dict_["cli"]["_reporter_definition"] = args.reporter if not args.rootdir: _rootdir = self.dict_["cli"].get("rootdir") if isinstance(_rootdir, str): self.dict_["cli"]["rootdir"] = os.path.expanduser(_rootdir) else: self.dict_["cli"]["rootdir"] = os.getcwd() else: self.dict_["cli"]["rootdir"] = os.path.abspath(args.rootdir) if not os.path.isdir(self.dict_["cli"]["rootdir"]): # pragma: no cover rootdir = self.dict_["cli"]["rootdir"] raise ProjectConfigInvalidConfig( f"Root directory '{rootdir}' must be an existing directory", ) # set rootdir as an internal environment variable to be # used by plugins os.environ["PROJECT_CONFIG_ROOTDIR"] = self.dict_["cli"]["rootdir"] self.dict_["cli"]["only_hints"] = ( self.dict_["cli"].get("only_hints") is True or args.only_hints is True )
[docs]def reporter_from_config(config: Config) -> Any: """Instanciate a reporter from a configuration object. Args: config (Config): Configuration object. """ config_cli = config.dict_.get("cli", {}) return get_reporter( config_cli.get("_reporter_definition", {}).get( "name", DEFAULT_REPORTER, ), config_cli.get("_reporter_definition", {}).get("kwargs", {}), config_cli.get("color", None), config_cli.get("rootdir", os.getcwd()), only_hints=config_cli.get("only_hints", False), )
[docs]def initialize_config(config_fpath: str) -> str: """Initialize the configuration. Args: config_fpath (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_fpath)), ) os.makedirs(config_dirpath, exist_ok=True) if not os.path.isfile(config_fpath): with open(config_fpath, "w", encoding="ascii") as f: f.write("") style_fpath = os.path.join(config_dirpath, "style.json5") config_file_basename = os.path.basename(config_fpath) 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_fpath, "w", encoding="ascii") 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, # noqa: FBT001, FBT002 ) -> 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_fpath, encoding="ascii") as f: config_lines = f.read().splitlines() add_separator = config_lines[-1] != "" if config_lines else False with open(config_fpath, "a", encoding="ascii") as f: if add_separator: f.write("\n") f.write(string) if config_file_basename == "pyproject.toml": config = read_config_from_pyproject_toml(config_fpath) if config: raise ProjectConfigAlreadyInitialized( f"{config_fpath}[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_fpath}[tool.project-config]" if os.path.isfile(config_fpath): with open(config_fpath, encoding="ascii") as f: if f.read(): raise ProjectConfigAlreadyInitialized(config_fpath) add_config_string_to_file(build_config_string()) create_default_style_file() return config_fpath