feat: create libretime_shared package (#1349)

* feat: create libretime_shared package

- We don't use pydantic.BaseSettings because of some
  incompatble loading behavior.

* fix: whitelist pydantic in pylintrc

* docs: update to new BaseConfig behavior

* docs: change confusing config filepath
This commit is contained in:
Jonas L 2022-01-06 14:40:52 +01:00 committed by GitHub
parent ba8b51af76
commit 3a615cafa0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 568 additions and 0 deletions

View file

View file

@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from os import PathLike
from pathlib import Path
from typing import Optional
from loguru import logger
from .logging import LogLevel, setup_logger
# pylint: disable=too-few-public-methods
class AbstractApp(ABC):
"""
Abstracts the creation of an application to reduce boilerplate code such
as logging setup.
"""
log_level: LogLevel
@property
@abstractmethod
def name(self) -> str:
...
def __init__(
self,
*,
verbosity: int,
log_filepath: Optional[PathLike] = None,
):
if log_filepath is not None:
log_filepath = Path(log_filepath)
self.log_level = setup_logger(verbosity=verbosity, filepath=log_filepath)
logger.info(f"Starting {self.name}...")

View file

@ -0,0 +1,65 @@
from typing import Callable
import click
from .config import DEFAULT_ENV_PREFIX
def cli_logging_options(func: Callable) -> Callable:
"""
Decorator function to add logging options to a click application.
This decorator add the following arguments:
- verbosity: int
- log_filepath: Path
"""
func = click.option(
"-v",
"--verbose",
"verbosity",
envvar=f"{DEFAULT_ENV_PREFIX}_VERBOSITY",
count=True,
default=0,
help="Increase logging verbosity (use -vvv to debug).",
)(func)
func = click.option(
"-q",
"--quiet",
"verbosity",
is_flag=True,
flag_value=-1,
help="Decrease logging verbosity.",
)(func)
func = click.option(
"--log-filepath",
"log_filepath",
envvar=f"{DEFAULT_ENV_PREFIX}_LOG_FILEPATH",
type=click.Path(),
help="Path to the logging file.",
default=None,
)(func)
return func
def cli_config_options(func: Callable) -> Callable:
"""
Decorator function to add config file options to a click application.
This decorator add the following arguments:
- config_filepath: Path
"""
func = click.option(
"--c",
"--config",
"config_filepath",
envvar=f"{DEFAULT_ENV_PREFIX}_CONFIG_FILEPATH",
type=click.Path(),
help="Path to the config file.",
default=None,
)(func)
return func

View file

@ -0,0 +1,101 @@
import sys
from os import environ
from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger
# pylint: disable=no-name-in-module
from pydantic import BaseModel, ValidationError
from pydantic.fields import ModelField
from yaml import YAMLError, safe_load
DEFAULT_ENV_PREFIX = "LIBRETIME"
# pylint: disable=too-few-public-methods
class BaseConfig(BaseModel):
"""
Read and validate the configuration from 'filepath' and os environment.
:param filepath: yaml configuration file to read from
:param env_prefix: prefix for the environment variable names
:returns: configuration class
"""
def __init__(
self,
*,
env_prefix: str = DEFAULT_ENV_PREFIX,
filepath: Optional[Path] = None,
) -> None:
file_values = self._load_file_values(filepath)
env_values = self._load_env_values(env_prefix)
try:
super().__init__(
**{
**file_values,
**env_values,
},
)
except ValidationError as error:
logger.critical(error)
sys.exit(1)
def _load_env_values(self, env_prefix: str) -> Dict[str, Any]:
return self._get_fields_from_env(env_prefix, self.__fields__)
def _get_fields_from_env(
self,
env_prefix: str,
fields: Dict[str, ModelField],
) -> Dict[str, Any]:
result: Dict[str, Any] = {}
for field in fields.values():
env_name = (env_prefix + "_" + field.name).upper()
if field.is_complex():
result[field.name] = self._get_fields_from_env(
env_name,
field.type_.__fields__,
)
else:
if env_name in environ:
result[field.name] = environ[env_name]
return result
# pylint: disable=no-self-use
def _load_file_values(
self,
filepath: Optional[Path] = None,
) -> Dict[str, Any]:
if filepath is None:
return {}
try:
return safe_load(filepath.read_text(encoding="utf-8"))
except YAMLError as error:
logger.critical(error)
sys.exit(1)
# pylint: disable=too-few-public-methods
class Database(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "libretime"
user: str = "libretime"
password: str = "libretime"
# pylint: disable=too-few-public-methods
class RabbitMQ(BaseModel):
host: str = "localhost"
port: int = 5672
name: str = "libretime"
user: str = "libretime"
password: str = "libretime"
vhost: str = "/libretime"

View file

@ -0,0 +1,117 @@
import sys
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional
from loguru import logger
if TYPE_CHECKING:
from loguru import Logger
logger.remove()
class LogLevel(NamedTuple):
name: str
no: int
def is_debug(self) -> bool:
return self.no <= 10
# See https://loguru.readthedocs.io/en/stable/api/logger.html#levels
ERROR = LogLevel(name="error", no=40)
WARNING = LogLevel(name="warning", no=30)
INFO = LogLevel(name="info", no=20)
DEBUG = LogLevel(name="debug", no=10)
TRACE = LogLevel(name="trace", no=5)
def level_from_verbosity(verbosity: int) -> LogLevel:
"""
Find logging level, depending on the verbosity requested.
-q -1 => ERROR
default 0 => WARNING
-v 1 => INFO
-vv 2 => DEBUG
-vvv 3 => TRACE
:param verbosity: verbosity (between -1 and 3) of the logger
:returns: log level guessed from the verbosity
"""
if verbosity < 0:
return ERROR
return [WARNING, INFO, DEBUG, TRACE][min(3, verbosity)]
def setup_logger(
verbosity: int,
filepath: Optional[Path] = None,
serialize: bool = False,
) -> LogLevel:
"""
Configure the logger and return the computed log level.
See https://loguru.readthedocs.io/en/stable/overview.html
:param verbosity: verbosity (between -1 and 3) of the logger
:param filepath: write logs to filepath
:param serialize: generate JSON formatted log records
:returns: log level guessed from the verbosity
"""
level = level_from_verbosity(verbosity)
handlers = [dict(sink=sys.stderr, level=level.no, serialize=serialize)]
if filepath is not None:
handlers.append(
dict(
sink=filepath,
enqueue=True,
level=level.no,
serialize=serialize,
rotation="12:00",
retention="7 days",
)
)
logger.configure(handlers=handlers)
return level
_empty_logger = deepcopy(logger)
def create_task_logger(
verbosity: int,
filepath: Path,
serialize: bool = False,
) -> "Logger":
"""
Create and configure an independent logger for a task, return the new logger.
See #creating-independent-loggers-with-separate-set-of-handlers in
https://loguru.readthedocs.io/en/stable/resources/recipes.html
:returns: new logger
"""
task_logger = deepcopy(_empty_logger)
level = level_from_verbosity(verbosity)
task_logger.configure(
handlers=[
dict(
sink=filepath,
enqueue=True,
level=level.no,
serialize=serialize,
rotation="12:00",
retention="7 days",
)
],
)
return task_logger

View file

@ -0,0 +1 @@
# Marker file for PEP 561.