"""
formelsammlung.envvar
~~~~~~~~~~~~~~~~~~~~~
Get environment variables and transform their type.
:copyright: (c) 2020, Christian Riedel and AUTHORS
:license: GPL-3.0-or-later, see LICENSE for details
""" # noqa: D205,D208,D400
import os
import re
from typing import Any, Iterable, NoReturn, Optional, Pattern, Set, Union
#: Default values to convert to ``True`` for environment variables.
TRUE_BOOL_VALUES = ("1", "y", "yes", "t", "True")
#: Default values to convert to ``False`` for environment variables.
FALSE_BOOL_VALUES = ("0", "n", "no", "f", "False")
#: Default regex to check if a string could be an :class:`int`.
INT_REGEX = r"^[\d]+(_\d+)*$"
#: Default regex to check if a string could be an :class:`float`.
FLOAT_REGEX = r"^[\d]+(_\d+)*\.\d+$"
[docs]class EnvVarGetter: # noqa: R0903
"""Class containing the config for :meth:`EnvVarGetter.getenv_typed`."""
def __init__( # noqa: R0913
self,
*,
raise_error_if_no_value: bool = False,
true_bool_values: Optional[Iterable] = None,
false_bool_values: Optional[Iterable] = None,
int_regex: Optional[str] = None,
float_regex: Optional[str] = None,
) -> None:
"""Initialize :class:`EnvVarGetter` with config values.
Use the :class:`EnvVarGetter` instance to call :meth:`EnvVarGetter.getenv_typed`
with the set config.
.. Note:: Parameters below are all keyword only.
:param raise_error_if_no_value: If ``True`` raises an :exc:`KeyError` when no
value is found by :meth:`EnvVarGetter.getenv_typed` for ``var_name`` and
``default`` is ``None``.
Default: ``False``
:param true_bool_values: Set of objects whose string representations are
matched case-insensitive against the environment variable's value to check
if the value is a ``True`` bool.
Default: :const:`TRUE_BOOL_VALUES`
:param false_bool_values: Set of objects whose string representations are
matched case-insensitive against the environment variable's value to check
if the value is a ``False`` bool.
Default: :const:`FALSE_BOOL_VALUES`
:param int_regex: Regex string to identify integers.
Default: :const:`INT_REGEX`
:param float_regex: Regex string to identify floats.
Default: :const:`FLOAT_REGEX`
"""
self.raise_error_if_no_value = raise_error_if_no_value
self._true_bool_values = set(true_bool_values or TRUE_BOOL_VALUES)
self._false_bool_values = set(false_bool_values or FALSE_BOOL_VALUES)
self._int_regex = int_regex or INT_REGEX
self._int_regex_pattern = re.compile(self._int_regex)
self._float_regex = float_regex or FLOAT_REGEX
self._float_regex_pattern = re.compile(self._float_regex)
@property
def true_bool_values(self) -> Set[str]:
"""Set of objects to identify a ``True`` boolean.
See parameters of :class:`EnvVarGetter`.
"""
# Get value for ``_true_bool_values``.
return self._true_bool_values
@true_bool_values.setter
def true_bool_values(self, value: Iterable) -> None:
"""Set new value for ``_true_bool_values``."""
self._true_bool_values = set(value)
@property
def false_bool_values(self) -> Set[str]:
"""Set of objects to identify a ``False`` boolean.
See parameters of :class:`EnvVarGetter`.
"""
# Get value for ``_false_bool_values``.
return self._false_bool_values
@false_bool_values.setter
def false_bool_values(self, value: Iterable) -> None:
"""Set new value for ``_false_bool_values``."""
self._false_bool_values = set(value)
@property
def int_regex(self) -> str:
"""Regex string used for checking if a string is an :class:`int`.
See parameters of :class:`EnvVarGetter`.
"""
# Get value for ``_int_regex``.
return self._int_regex
@int_regex.setter
def int_regex(self, value: str) -> None:
"""Set new value for ``int_regex`` and update ``int_regex_pattern``."""
self._int_regex = value
self._int_regex_pattern = re.compile(self._int_regex)
@property
def int_regex_pattern(self) -> Pattern[str]:
"""Regex pattern of :meth:`EnvVarGetter.int_regex`.
Cannot be set. Set via :meth:`EnvVarGetter.int_regex`.
"""
return self._int_regex_pattern
@int_regex_pattern.setter
def int_regex_pattern(self, value: Any) -> NoReturn: # noqa: R0201
"""Error if called."""
raise AttributeError(
"`int_regex_pattern` cannot be set directly. "
"Set as string via `int_regex`."
)
@property
def float_regex(self) -> str:
"""Regex string used for checking if a string is a :class:`float`.
See parameters of :class:`EnvVarGetter`.
"""
# Get value for ``_float_regex``.
return self._float_regex
@float_regex.setter
def float_regex(self, value: str) -> None:
"""Set new value for ``float_regex`` and update ``float_regex_pattern``."""
self._float_regex = value
self._float_regex_pattern = re.compile(self._float_regex)
@property
def float_regex_pattern(self) -> Pattern[str]:
"""Regex pattern of :meth:`EnvVarGetter.float_regex`.
Cannot be set. Set via :meth:`EnvVarGetter.float_regex`.
"""
return self._float_regex_pattern
@float_regex_pattern.setter
def float_regex_pattern(self, value: Any) -> NoReturn: # noqa: R0201
"""Error if called."""
raise AttributeError(
"`float_regex_pattern` cannot be set directly. "
"Set as string via `float_regex`."
)
def _guess_bool(self, value: str) -> Optional[bool]:
"""Guess if value is a ``bool``."""
#: Guess if `True`
if value.casefold() in (str(b).casefold() for b in self._true_bool_values):
return True
#: Guess if `False`
if value.casefold() in (str(b).casefold() for b in self._false_bool_values):
return False
return None
def _guess_num(self, value: str) -> Optional[Union[int, float]]:
"""Guess if value is an ``int`` or ``float``."""
#: Guess if `int`
if self._int_regex_pattern.fullmatch(value):
return int(value)
#: Guess if `float`
if self._float_regex_pattern.fullmatch(value):
return float(value)
return None
[docs] def getenv_typed(
self,
var_name: str,
default: Any = None,
rv_type: Optional[type] = None,
) -> Any:
"""Wrap :func:`os.getenv` to adjust the type of the return values.
Instead of returning the environments variable's value as :class:`str` like
:func:`os.getenv` you can set ``rv_type`` to a :class:`type` to convert the
value into. If ``rv_type`` is not set the :class:`type` gets guessed and used
for conversion.
**Guessable types are (checked in this order):**
- :class:`bool`
- :class:`int`
- :class:`float`
- :class:`str` (fallback)
For :class:`bool` guessing the value returned by :func:`os.getenv` is compared
against :meth:`EnvVarGetter.true_bool_values` and
:meth:`EnvVarGetter.false_bool_values` and if a match is found returns the
corresponding boolean.
For :class:`int` guessing the value returned by :func:`os.getenv` is checked by
the regex :meth:`EnvVarGetter.int_regex_pattern` which can be changed by setting
:meth:`EnvVarGetter.int_regex`.
For :class:`float` guessing the value returned by :func:`os.getenv` is checked
by the regex :meth:`EnvVarGetter.float_regex_pattern` which can be changed by
setting :meth:`EnvVarGetter.float_regex`.
.. Warning:: Because :class:`bool` is guessed before :class:`int` ``0`` and
``1`` are converted into :class:`bool` instead of :class:`int` when
``rv_type`` is not set.
**How to use:**
.. testsetup::
import os
from formelsammlung.envvar import EnvVarGetter
.. doctest::
>>> os.environ["TEST_ENV_VAR"] = "2"
>>> getter = EnvVarGetter()
>>> getter.getenv_typed("TEST_ENV_VAR", 1, int)
2
.. testcleanup::
os.environ["TEST_ENV_VAR"] = ""
:param var_name: Name of the environment variable.
:param default: Default value if no value is found for ``var_name``.
Default: ``None``.
:param rv_type: Type the value of the environment variable should be changed
into. If not set or set to ``None`` the type gets guessed.
Default: ``None``.
:raises KeyError: If ``raise_error_if_no_value`` is ``True`` and no value is
found for ``var_name`` and ``default`` is ``None``.
:raises KeyError: If ``rv_type`` is set to :class:`bool` and value from
``var_name`` or ``default`` is not found in ``true_bool_values`` or
``false_bool_values``.
:return: Value for ``var_name`` or ``default`` converted to ``rv_type``
or guessed type.
"""
env_var = os.getenv(var_name, default)
if not env_var and default is None:
if self.raise_error_if_no_value:
raise KeyError(
f"Environment variable '{var_name}' not set "
"or empty and no default."
) from None
return None
#: Convert to given `rv_type` if set.
if rv_type and rv_type is not bool:
return rv_type(env_var)
env_var = str(env_var)
#: Guess bool value
bool_val = self._guess_bool(env_var)
if bool_val is not None:
return bool_val
if rv_type:
raise KeyError(
f"Environment variable '{var_name}' has an invalid Boolean value.\n"
f"For true use any of: {self._true_bool_values}\n"
f"For false use any of: {self._false_bool_values}"
) from None
#: Guess num value
num_val = self._guess_num(env_var)
if num_val is not None:
return num_val
return env_var
[docs]def getenv_typed(
var_name: str, default: Any = None, rv_type: Optional[type] = None, **kwargs: Any
) -> Any:
"""Shortcut for ``EnvVarGetter(...).getenv_typed(...)``.
**How to use:**
.. testsetup::
import os
from formelsammlung.envvar import getenv_typed
.. doctest::
>>> os.environ["TEST_ENV_VAR"] = "2"
>>> getenv_typed("TEST_ENV_VAR", 1, int)
2
.. testcleanup::
os.environ["TEST_ENV_VAR"] = ""
:param var_name: Same argument as for and gets given to
:meth:`EnvVarGetter.getenv_typed`.
:param default: Same argument as for and gets given to
:meth:`EnvVarGetter.getenv_typed`.
:param rv_type: Same argument as for and gets given to
:meth:`EnvVarGetter.getenv_typed`.
:param kwargs: Arguments taken by :class:`EnvVarGetter`
:return: Return value of :meth:`EnvVarGetter.getenv_typed`.
""" # noqa: D402
return EnvVarGetter(**kwargs).getenv_typed(var_name, default, rv_type)