# pylint: disable=too-few-public-methods
from importlib.util import module_from_spec, spec_from_file_location
import logging
from logging.handlers import WatchedFileHandler
import os
from pathlib import Path
import re
import sys
from typing import Any, Dict, Optional
from git import InvalidGitRepositoryError, Repo
from github3 import GitHub, login
from pydantic import BaseSettings
import toml
# CLI only configuration
# The CLI only configuration items are here, to keep the config at one place,
# but these options not used at other places. Please keep them here and do not
# create a CLI configuration
DEFAULT_GENERATE_REPORT = True
# Other configuration
DEFAULT_ALLOW_PUSH: bool = True
DEFAULT_DRY_RUN: bool = False
DEFAULT_RULE_CAN_ABORT: bool = False
DEFAULT_GIT_BRANCH_NAME: str = "hammurabi"
DEFAULT_GIT_BASE_NAME: str = "master"
DEFAULT_REPOSITORY: str = ""
DEFAULT_REPORT_NAME: Path = Path("hammurabi_report.json")
DEFAULT_GITHUB_TOKEN: str = ""
DEFAULT_LOG_LEVEL: str = "INFO"
DEFAULT_LOG_PATH: Path = Path("./hammurabi.log")
DEFAULT_LOG_FORMAT: str = logging.BASIC_FORMAT
DEFAULT_PILLAR_CONFIG: Path = Path("pillar.conf.py")
DEFAULT_PILLAR_NAME: str = "pillar"
DEFAULT_PROJECT_CONFIG = "pyproject.toml"
[docs]class CommonSettings(BaseSettings):
"""
Common settings which applies to both TOML and CLI
configuration of Hammurabi.
``Pillar`` configuration is intentionally not listed since
it is represented as a string in the TOML configuration, but
used the parsed variable in the CLI configuration.
"""
allow_push: bool = DEFAULT_ALLOW_PUSH
dry_run: bool = DEFAULT_DRY_RUN
rule_can_abort: bool = DEFAULT_RULE_CAN_ABORT
git_branch_name: str = DEFAULT_GIT_BRANCH_NAME
git_base_name: str = DEFAULT_GIT_BASE_NAME
repository: str = DEFAULT_REPOSITORY
report_name: Path = DEFAULT_REPORT_NAME
[docs] class Config:
"""
BaseSettings' config describing how the settings will be handled.
The given ``env_prefix`` will make sure that settings can be read from
environment variables starting with ``HAMMURABI_``.
"""
env_prefix = "hammurabi_"
[docs]class TOMLSettings(CommonSettings):
"""
TOML Project configuration settings. Most of the fields
are used to compose other configuration fields like
``github_token`` or ``pillar``.
"""
github_token: str = DEFAULT_GITHUB_TOKEN
log_level: str = DEFAULT_LOG_LEVEL
log_path: Optional[Path] = DEFAULT_LOG_PATH
log_format: str = DEFAULT_LOG_FORMAT
pillar_config: Path = DEFAULT_PILLAR_CONFIG
pillar_name: str = DEFAULT_PILLAR_NAME
[docs]class Settings(CommonSettings):
"""
CLI related settings which are directly needed for the
execution.
"""
pillar: object = None
[docs]class Config:
"""
Simple configuration object which used across Hammurabi.
The :class:`Config` loads the given ``pyproject.toml`` according
to PEP-518.
.. warning::
When trying to use GitHub based laws without an initialized GitHub
client (or invalid token), a warning will be thrown at the beginning
of the execution. In case a PR open is attempted, a ``RuntimeError``
will be raised
"""
def __init__(self) -> None:
try:
repo = Repo(self.__get_repo_path())
except InvalidGitRepositoryError as exc:
logging.error('"%s" is not a git repository', str(exc))
repo = None
self.repo: Repo = repo
self.github: Optional[GitHub] = None
self.settings: Settings = Settings()
@staticmethod
def __setup_logging(project_config: TOMLSettings) -> None:
"""
Set the logging configuration of the root logger. The root logger is
intentionally configured and it is not a mistake.
:param project_config: Parsed TOMLSettings
:type project_config: :class:`hammurabi.config.TOMLSettings`
"""
logging.root.setLevel(project_config.log_level)
formatter = logging.Formatter(project_config.log_format)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
logging.root.addHandler(stream_handler)
if project_config.log_path:
file_handler = WatchedFileHandler(str(project_config.log_path))
file_handler.setFormatter(formatter)
logging.root.addHandler(file_handler)
@staticmethod
def __get_repo_path() -> Path:
"""
Get repository path which is the current working directory.
:return: Current working directory where Hammurabi is executed
:rtype: Path
"""
return Path(".").absolute()
@staticmethod
def __load_pyproject_toml(config_file: Path) -> TOMLSettings:
"""
Load and parse the given ``pyproject.toml`` file.
:param config_file: Path of the pyproject.toml file
:type config_file: Path
:return: Returns the parsed configuration
:rtype: :class:`hammurabi.config.TOMLSettings`
"""
return TOMLSettings(
**toml.load(config_file).get("tool", {}).get("hammurabi", {})
)
@staticmethod
def __load_pillar_config(project_config: TOMLSettings) -> object:
"""
Load ``pillar`` configuration based on the dotted style path in the
``pyproject.toml``, set by ``config`` configuration section.
:param project_config: Parsed TOMLSettings
:type project_config: :class:`hammurabi.config.TOMLSettings`
:return: Returns the initialized :class:`hammurabi.pillar.Pillar`
:rtype: :class:`pydantic.PyObject`
"""
# Pillar configuration file
pillar_config = project_config.pillar_config.expanduser()
# Load the configuration from pillar config module to runtime
spec = spec_from_file_location(
pillar_config.name.replace(".py", ""), os.path.expandvars(pillar_config)
)
module = module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore
# Pillar config variable name
return getattr(module, project_config.pillar_name)
@staticmethod
def __get_settings_value(parameter: str, fallback: Any) -> Any:
"""
Get a specific setting parameter's value. This helper function
will prefer environment variables over the fallback value, to
keep the configuration order.
:param parameter: Name of the setting
:type parameter: str
:param fallback: Value set in the configuration file
:type fallback: Any
:return: Returns the value read from environment or config file
:rtype: Any
"""
prefix = Settings.Config.env_prefix
return os.environ.get(f"{prefix}{parameter}", fallback)
def __merge_settings(self, loaded_settings: Dict[str, Any]) -> Settings:
"""
Merge the configuration parsed from pyproject.toml and set by
environment variables. To keep the configuration loading order,
the environment settings will be used over those found in the
TOML file.
Config priority:
1. CLI arguments (set by the CLI)
2. ENV Variables (handled by pydantic settings)
3. Config from file (handled by ``Config`` object)
4. Default config (handled by pydantic settings)
:param loaded_settings:
:type loaded_settings: Dict[str, Any]
"""
merge_result = {}
for setting, value in loaded_settings.items():
merge_result[setting] = self.__get_settings_value(setting, value)
return Settings(**merge_result)
def __get_fallback_repository(self) -> str:
"""
Figure out the fallback owner/repository based on the remote url of the git repo.
:return: Returns the owner/repository pair
:rtype: str
"""
repo_url: str = self.repo.remote().url
if re.match(r"^http(s)?://", repo_url):
repo = "/".join(repo_url.split("/")[-2:])
else:
repo = repo_url.split(":")[-1]
return repo.replace(".git", "")
[docs] def load(self):
"""
Handle configuration loading from project toml file and make sure
the configuration are initialized and merged. Also, make sure that
logging is set properly. Before loading the configuration, it is a
requirement to set the ``HAMMURABI_SETTINGS_PATH`` as it will contain
the path to the ``toml`` file what Hammurabi expects. This is needed
for cases when the 3rd party rules would like to read the configuration
of Hammurabi.
... note:
The ``HAMMURABI_SETTINGS_PATH`` environment variable is set by the CLI
by default, so there is no need to set if no 3rd party rules are used
or those rules are not loading config.
:raises: Runtime error if ``HAMMURABI_SETTINGS_PATH`` environment variable is not
set or an invalid git repository was given.
"""
if not self.repo:
raise RuntimeError(f'"{self.__get_repo_path()}" is not a git repository.')
settings_path = Path(
os.path.expandvars(
os.environ.get("HAMMURABI_SETTINGS_PATH", DEFAULT_PROJECT_CONFIG)
)
).expanduser()
if not settings_path.exists():
raise RuntimeError(
f'Environment variable "HAMMURABI_SETTINGS_PATH" ({settings_path}) '
"does not exists. Please make sure that you set the environment variable "
"or CLI ``-c/--config`` flag properly. You must either define the"
"environment variable or use hammurabi as a CLI tool."
)
# Hammurabi CLI configuration file
project_config = self.__load_pyproject_toml(settings_path)
# Merge settings and make sure we keep config priority
# Override the default settings by the merged ones
self.settings = self.__merge_settings(
{
"pillar": self.__load_pillar_config(project_config),
"git_base_name": project_config.git_base_name,
"git_branch_name": project_config.git_branch_name,
"dry_run": project_config.dry_run,
"rule_can_abort": project_config.rule_can_abort,
"report_name": project_config.report_name,
}
)
if not project_config.repository:
self.settings.repository = self.__get_fallback_repository()
# Set after self.settings is set since the following
# may depend on settings read from environment or config file
self.github = login(token=project_config.github_token)
self.__setup_logging(project_config)
logging.debug('Successfully loaded "%s"', settings_path)
logging.debug('Successfully loaded "%s"', project_config.pillar_config)
if not self.github:
logging.warning("Missing or invalid GitHub token")
config = Config() # pylint: disable=invalid-name