# vim:tw=0:ts=4:sw=4:et:norl
# Author: Landon Bouma <https://tallybark.com/>
# Project: https://github.com/doblabs/config-decorator#🎀
# License: MIT
# Copyright © 2019-2020 Landon Bouma. All rights reserved.
"""Class to manage key-value settings."""
import os
import sys
from gettext import gettext as _
__all__ = ("KeyChainedValue",)
[docs]
class KeyChainedValue(object):
"""Represents one setting of a section of a hierarchical settings configuration.
.. automethod:: __init__
"""
_envvar_prefix = ""
_envvar_warned = False
[docs]
def __init__(
self,
section=None,
name="",
default_f=None,
value_type=None,
allow_none=False,
# Optional.
choices="",
doc="",
ephemeral=False,
hidden=False,
validate=None,
conform=None,
recover=None,
):
"""Inits a :class:`KeyChainedValue` object.
Do not create these objects directly, but instead
use the
:func:`config_decorator.config_decorator.ConfigDecorator.settings`
decorator.
Except for ``section``, the following arguments may be specified
in the decorator call.
For instance::
@property
@RootSectionBar.setting(
"An example setting.",
name='foo-bar',
value_type=bool,
hidden=True,
# etc.
)
def foo_bar(self):
return 'True'
Args:
section: A reference to the section that contains this setting
(a :class:`config_decorator.config_decorator.ConfigDecorator`).
name: The setting name, either inferred from the class method that
is decorated, or specified explicitly.
default_f: A method (i.e., the decorated class method)
that generates the default setting value.
value_type: The setting type, either inferred from the type of
the default value, or explicitly indicated.
It's often useful to explicitly set ``bool`` types
so that the default function can return a ``'True'``
or ``'False'`` string.
allow_none: True if the value is allowed to be ``None``, otherwise
when the value is set, it will be passed to the type
converted, which might fail on None, or produce
unexpected results
(such as converting ``None`` to ``'None'``).
choices: A optional list of valid values, used to validate input
when setting the value.
doc: Helpful text about the setting, which your application could
use to show the user. The ``doc`` can specified as a keyword
argument, or as the first positional argument to the decorator.
ephemeral: If True, the setting is meant not to be persisted
between sessions (e.g., ``ephemeral`` settings are
excluded on a call to
:meth:`config_decorator.config_decorator.ConfigDecorator.apply_items`
.)
hidden: If True, the setting is excluded from an output operation
if the value is the same as the setting's default value.
validate: An optional function to validate the value when set
from user input. If the validate function returns a
falsey value, setting the value raises ``ValueError``.
conform: If set, function used to translate config value to the
value used internally. Useful for log levels, datetime, etc.
recover: If set, function used to convert internal value back to
storable value. Useful to covert log level back to name, etc.
"""
self._section = section
self._name = name
self._default_f = self._prepare_default_f(default_f, value_type, allow_none)
self._choices = choices
self._doc = doc
self._ephemeral = ephemeral
self._hidden = hidden
self._validate_f = validate
self._conform_f = conform
self._recover_f = recover
self._value_allow_none = allow_none
self._value_type = self._deduce_value_type(value_type)
# These attributes will only be set if some particular
# source specifies a value:
# self._val_forced
# self._val_cliarg
# self._val_envvar
# self._val_config
@property
def name(self):
"""Returns the setting name."""
return self._name
@property
def default(self):
"""Returns the default setting value."""
return self._default_f(self._section)
def _prepare_default_f(self, default_f=None, value_type=None, allow_none=False):
if default_f is not None:
return default_f
default_val = None
# REFER: _deduce_default_type knows: None, bool, int, list, str.
if allow_none:
default_val = None
elif value_type is None:
# Means value_type wasn't specified.
default_val = ""
elif value_type is bool:
default_val = False
elif value_type is int:
default_val = 0
elif value_type is list:
default_val = []
elif value_type is str:
default_val = ""
else:
# Fallback is to stringify unknown type.
# - Though really maybe this should be an error, as it's
# truly unexpected. But tolerable.
default_val = ""
return lambda x: default_val
def _deduce_value_type(self, value_type=None):
if value_type is not None:
# Caller can specify, say, a function to do type conversion,
# but they're encouraged to stick to builtin types, and to
# use `conform` if they need to change values on input.
if value_type is list:
return self._typify_list
return value_type
elif self.ephemeral:
return lambda val: val
return self._deduce_default_type()
def _deduce_default_type(self):
default_value = self.default
if default_value is None:
# If user wrote default method to return None, then obviously
# implicitly the setting allows the None value.
self._value_allow_none = True
# Furthermore, the value type is implicitly whatever, because
# the user did not specify the type of None that is the default.
# So rather than assume, the type function is just the identity.
# (The user can set value_type to be explicit about the type.)
return lambda val: val
elif isinstance(default_value, bool):
return bool
elif isinstance(default_value, int):
return int
elif isinstance(default_value, list):
# Because ConfigObj auto-detects list-like values,
# we might get a string value in a list-type setting,
# which we don't want to ['s', 'p', 'l', 'i', 't'].
# So rather than a blind:
# return list
# we gotta be smarter.
return self._typify_list
elif isinstance(default_value, str):
return str
# We could default to, say, str, or we could nag user to either
# add another `elif` here, or to fix their default return value.
msg = f" ({_('Unrecognized value type')}: " f"‘{type(default_value).__name__}’)"
raise NotImplementedError(msg)
@property
def doc(self):
"""Returns the setting help text."""
return self._doc
@property
def ephemeral(self):
"""Returns the ephemeral state."""
if callable(self._ephemeral):
if self._section is None:
return False
return self._ephemeral(self)
return self._ephemeral
[docs]
def find_root(self):
"""Returns the topmost section object."""
# (lb): This function probably not useful, but offered as parity
# to what's in ConfigDecorator. And who knows, maybe a developer
# will find useful from a debug prompt.
return self._section.find_root()
@property
def hidden(self):
"""Returns the hidden state."""
if callable(self._hidden):
if self._section is None:
# FIXME/2019-12-23: (lb): I think this is unreachable,
# because self._section is only None when config is
# being built, but hidden not called during that time.
return False
return self._hidden(self)
return self._hidden
@property
def persisted(self):
"""Returns True if the setting value was set via :meth:`value_from_config`."""
return hasattr(self, "_val_config")
def _typify(self, value):
if value is None:
if self._value_allow_none:
return value
raise ValueError(_(" (No “None” values allowed)"))
if self._value_type is bool:
if isinstance(value, bool):
return value
elif value == "True":
return True
elif value == "False":
return False
else:
raise ValueError(_(" (Expected a bool, or “True” or “False”)"))
if self._value_type is int:
try:
return int(value)
except ValueError:
raise ValueError(f" ({_('Expected an int')})")
try:
value = self._value_type(value)
except Exception as err:
# Used as 'addendum' to broader error message.
raise ValueError(f" ({err})")
return value
def _typify_list(self, value):
# Handle ConfigObj parsing a string without finding commas to
# split on, but the @setting indicating it's a list; or a
# default method returning [] so we avoid calling list([]).
if isinstance(value, list):
return value
return [value]
[docs]
def walk(self, visitor):
visitor(self._section, self)
# ***
def __str__(self):
return "{}{}{}: {}".format(
self._section.section_path(),
self._section.SEP,
self._name,
self.value,
)
# ***
@property
def value(self):
"""Returns the setting value read from the highest priority source.
Returns:
The setting value from the highest priority source,
as determined by the order of this list:
- If the setting value was forced,
by a call to the :meth:`value_from_forced` setter,
that value is returned.
- If the setting value was read from a command line argument,
by a call to the :meth:`value_from_cliarg` setter,
that value is returned.
- If the setting value was read from an environment variable,
by a call to the :meth:`value_from_envvar` setter,
that value is returned.
- If the setting value was read from the dictionary source,
by a call to the :meth:`value` or :meth:`value_from_config` setters,
that value is returned.
- Finally, if a value was not obtained from any of the above
sources, the default value is returned.
"""
# Honor forced values foremost.
try:
return self.value_from_forced
except AttributeError:
pass
# Honor CLI-specific values secondmost.
try:
return self.value_from_cliarg
except AttributeError:
pass
# Check the environment third.
try:
return self.value_from_envvar
except KeyError:
pass
# See if the config value was specified by the config that was read.
try:
return self.value_from_config
except AttributeError:
pass
# Nothing found so far! Finally just return the default value.
return self._value_conform_and_validate(self.default, is_default=True)
@value.setter
def value(self, value):
"""Sets the setting value to the value supplied.
Args:
value: The new setting value.
The value is assumed to be from the config,
i.e., this method is an alias to
the :meth:`value_from_config` setter.
"""
orig_value = value
value = self._value_conform_and_validate(value)
# Using the `value =` shortcut, or using `section['key'] = `,
# is provided as a convenient way to inject values from the
# config file, or that the user wishes to set in the file.
# Don't call the wrapper, which would call conform-validate again.
# NOPE: self.value_from_config = value
self._val_config = value
self._val_origin = orig_value
def _value_conform_and_validate(self, value, is_default=False):
def _corformidate():
_value = value
addendum = None
# Don't validate the default value. One use case is a config setting
# for a file path: the @setting might specify a validate() function,
# but if that fails or if user does not specify the setting, we still
# want the code to be able to query the setting value (which will
# fallback to the default value), in which case do not raise on
# validation error.
# MAYBE/2020-12-05 15:41: Speaking of which: I'm curious why I
# never added a 'required' attribute. Maybe because I'm taking
# an ethical stance that config is never mandatory (though the
# client code is always welcome to print a USAGE message and to
# exit early if some key piece of config is missing, but it is
# not the job of the config definition library to enforce it,
# “question mark”.)
if addendum is None and not is_default:
addendum = _validate(_value)
if addendum is None:
try:
_value = _conform_or_typify(_value)
except Exception as err:
addendum = f" [{repr(err)}]"
if addendum is not None:
raise ValueError(
_("Unrecognized value for setting ‘{}’: “{}”{}").format(
self._name,
value,
addendum,
),
)
return _value
def _conform_or_typify(_value):
if self._conform_f is not None:
return self._conform_f(_value)
return self._typify(value)
def _validate(_value):
# Returns None if valid value, or string if it's not.
addendum = None
if self._validate_f:
try:
# The caller's validate will either raise or return a truthy.
if not self._validate_f(_value):
addendum = ""
except Exception as err:
addendum = str(err)
elif self._choices:
if _value not in self._choices:
addendum = _(" (Choose from: ‘{}’)").format(
"’, ‘".join(self._choices)
)
return addendum
return _corformidate()
# ***
@property
def value_from_default(self):
"""Returns the conformed default value."""
return self._value_conform_and_validate(self.default, is_default=True)
# ***
@property
def value_from_forced(self):
"""Returns the "forced" setting value."""
return self._val_forced
@value_from_forced.setter
def value_from_forced(self, value_from_forced):
"""Sets the "forced" setting value, which supersedes values from all
other sources.
Args:
value_from_forced: The forced setting value.
"""
self._val_forced = self._value_conform_and_validate(value_from_forced)
# ***
@property
def value_from_cliarg(self):
"""Returns the "cliarg" setting value."""
return self._val_cliarg
@value_from_cliarg.setter
def value_from_cliarg(self, value_from_cliarg):
"""Sets "cliarg" setting value, which supersedes envvar, config, and default.
Args:
value_from_cliarg: The forced setting value.
"""
self._val_cliarg = self._value_conform_and_validate(value_from_cliarg)
# ***
@property
def value_from_envvar(self):
"""Returns the "envvar" setting value, sourced from the environment when called.
A name derived from a special prefix, the section path,
and the setting name is used to look for an environment
variable of the same name.
For example, consider that an application use the prefix
"CFGDEC\\_", and the setting is under a subsection called
"pokey" which is under a topmost section called "hokey".
If the setting is named "foot",
then the environment variable would be named,
"CFGDEC_HOKEY_POKEY_FOOT".
"""
if self.warn_if_no_envvar_prefix():
raise KeyError
normal_name = self._section._normalize_name(self._name)
environame = "{}{}_{}".format(
KeyChainedValue._envvar_prefix,
self._section.section_path(sep="_").upper(),
normal_name.upper(),
)
envval = os.environ[environame]
envval = self._value_conform_and_validate(envval)
return envval
[docs]
def warn_if_no_envvar_prefix(self):
if KeyChainedValue._envvar_prefix:
return False
if not KeyChainedValue._envvar_warned:
# Warn the DEV that they didn't wire their app 100%. This breaks
# the fourth wall, but don't care (that is, this is a library,
# and generally not for us to emit errors to the end user, but
# the end user here should be the DEV during testing).
err_msg = "WARNING: You should set KeyChainedValue._envvar_prefix"
print(err_msg, file=sys.stderr)
KeyChainedValue._envvar_warned = True
return True
# ***
@property
def value_from_config(self):
"""Returns the "config" setting value."""
return self._val_config
@value_from_config.setter
def value_from_config(self, value_from_config):
"""Sets the "config" setting value, which supersedes the default value.
Args:
value_from_config: The forced setting value.
"""
orig_value = value_from_config
self._val_config = self._value_conform_and_validate(value_from_config)
self._val_origin = orig_value
[docs]
def forget_config_value(self):
"""Removes the "config" setting value set by the :meth:`value_from_config`
setter."""
try:
del self._val_config
except AttributeError:
pass
# ***
@property
def value_unmutated(self):
"""Returns the storable config value, generally just the stringified value."""
try:
# Prefer the config value as original input, i.e., try to keep
# the output same as user's input. But still cast to string.
# Mostly just avoid whatever self.conform_f may have done.
return str(self._val_origin)
except AttributeError:
# No config value set, so stringify the most prominent value.
if self._recover_f:
return self._recover_f(self.value)
else:
return str(self.value)
# ***
@property
def asobj(self):
"""Returns self, behaving as identify function (need to quack like
``ConfigDecorator``)."""
return self
# ***
@property
def source(self):
"""Returns the setting value source.
Returns:
The name of the highest priority source,
as determined by the order of this list:
- If the setting value was forced,
by a call to the :meth:`value_from_forced` setter,
the value 'forced' is returned.
- If the setting value was read from a command line argument,
by a call to the :meth:`value_from_cliarg` setter,
the value 'cliarg' is returned.
- If the setting value was read from an environment variable,
by a call to the :meth:`value_from_envvar` setter,
the value 'envvar' is returned.
- If the setting value was read from the dictionary source,
by a call to the :meth:`value` or :meth:`value_from_config` setters,
the value 'config' is returned.
- Finally, if a value was not obtained from any of the above
sources, the value 'default' is returned.
"""
# Honor forced values foremost.
try:
return self.value_from_forced and "forced"
except AttributeError:
pass
# Honor CLI-specific values secondmost.
try:
return self.value_from_cliarg and "cliarg"
except AttributeError:
pass
# Check the environment third.
try:
return self.value_from_envvar and "envvar"
except KeyError:
pass
# See if the config value was specified by the config that was read.
try:
return self.value_from_config and "config"
except AttributeError:
pass
# Nothing found so far! Finally just return the default value.
return "default"