diff --git a/iubeo/__init__.py b/iubeo/__init__.py index fab0bcb..39a21ef 100644 --- a/iubeo/__init__.py +++ b/iubeo/__init__.py @@ -1,11 +1,24 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" -from .casters import boolean, comma_separated_list +from .casters import ( + boolean, + caster, + comma_separated_float_list, + comma_separated_int_list, + comma_separated_list, + integer, + string, +) from .config import ConfigError, config __all__ = [ + "caster", "config", "ConfigError", - "comma_separated_list", "boolean", + "comma_separated_list", + "comma_separated_int_list", + "comma_separated_float_list", + "integer", + "string", ] diff --git a/iubeo/casters.py b/iubeo/casters.py index d0255f2..0cd0a41 100644 --- a/iubeo/casters.py +++ b/iubeo/casters.py @@ -2,15 +2,42 @@ Utility functions to cast the string value from environment to python types. """ -from .utils import raise_config_error_instead +from functools import wraps -@raise_config_error_instead +def caster(f): + sentinel = object() + + def wrapper(missing_default=sentinel, error_default=sentinel): + @wraps(f) + def function_clone(value): + return f(value) + + if missing_default is not sentinel: + function_clone.missing_default = missing_default + if error_default is not sentinel: + function_clone.error_default = error_default + return function_clone + + return wrapper + + +@caster def comma_separated_list(value): return value.split(",") -@raise_config_error_instead +@caster +def comma_separated_int_list(value): + return [int(i) for i in value.split(",")] + + +@caster +def comma_separated_float_list(value): + return [float(i) for i in value.split(",")] + + +@caster def boolean(value): _truthy = ["true", "True", "1"] _false = ["false", "False", "0"] @@ -21,3 +48,7 @@ def boolean(value): return False else: raise ValueError('Value "{}" can not be parsed into a boolean.'.format(value)) + + +string = caster(str) +integer = caster(int) diff --git a/iubeo/config.py b/iubeo/config.py index 0332ff8..396ac69 100644 --- a/iubeo/config.py +++ b/iubeo/config.py @@ -1,41 +1,56 @@ import copy import os -from typing import AnyStr, Callable, Dict, Optional, Tuple, Union from .exceptions import ConfigError -from .utils import raise_config_error_instead - -ConfigFormat = Dict[AnyStr, Union["ConfigFormat", Tuple[AnyStr, Callable]]] class Config(dict): - __setattr__ = dict.__setitem__ - - def __getattr__(self, item): - value = dict.__getitem__(self, item) - if isinstance(value, list): - var, cast = value - return raise_config_error_instead(cast)(os.environ.get(var)) - return value - _START_NODE_NAME = "iubeo_data" + __setattr__ = dict.__setitem__ + __getattr__ = dict.__getitem__ + @classmethod def _fix_end_node_name(cls, name, sep): # Removes sep+_START_NODE_NAME+sep from the end node name. return name.replace(sep + cls._START_NODE_NAME, "").lstrip(sep) + @staticmethod + def _cast_value(data, key, caster, environment_key): + try: + data[key] = caster(os.environ[environment_key]) + except KeyError: + default = getattr(caster, "missing_default", None) + if default: + data[key] = default + else: + raise ConfigError( + f"Environment variable {environment_key} not found." + f" Please set it or provide a `missing_default` to your caster." + ) + except Exception as e: + default = getattr(caster, "error_default", None) + if default: + data[key] = default + else: + raise ConfigError( + f"Error while parsing {environment_key}='{os.environ[environment_key]}' with '{caster}'." + " Please check the value and the caster or provide an `error_default` to your caster." + ) from e + @classmethod def _create(cls, data: dict, prefix: str = "", sep: str = "__"): prefix = prefix or "" + mutated = {} for key, value in data.items(): mutated = {} if isinstance(value, dict): mutated = {prefix + sep + key: cls._create(value, prefix + sep + key, sep)} elif callable(value): - data[key] = [cls._fix_end_node_name(prefix + sep + key, sep), value] + cls._cast_value(data, key, value, cls._fix_end_node_name(prefix + sep + key, sep)) + else: - raise ConfigError("Values either must be callables or other mappings, not {}.".format(value)) + raise ConfigError(f"Values either must be callables or other mappings, not {type(value)}. Key={key}.") return cls(**mutated, **data) @classmethod @@ -60,18 +75,6 @@ def create(cls, data: dict, prefix: str = "", sep: str = "__"): # Which has, well, a side effect of mutating the object for the end user, hence: deepcopy. return cls.from_data(cls._create(data, prefix, sep).get(cls._START_NODE_NAME)) - @classmethod - def _final_nodes(cls, data: ConfigFormat): - for key, value in data.items(): - if isinstance(value, list): - yield value[0] - elif isinstance(value, dict): - yield from cls._final_nodes(value) - - @property - def final_nodes(self): - return [*self._final_nodes(self)] - -def config(data, *, prefix: Optional[str] = None, sep: str = "__"): +def config(data, *, prefix: str | None = None, sep: str = "__"): return Config.create(data, prefix, sep) diff --git a/iubeo/utils.py b/iubeo/utils.py deleted file mode 100644 index 58e6b30..0000000 --- a/iubeo/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -from functools import wraps - -from .exceptions import ConfigError - - -def raise_config_error_instead(f): - if hasattr(f, "_raises_config_error") and f._raises_config_error: - # This means that we already have decorated previously, so shortcut and don't re-decorate it. - # Avoiding nested ConfigErrors - return f - - @wraps(f) - def wrapper(value): - try: - return f(value) - except Exception as e: - exc = ConfigError("Error while parsing with '{}' with '{}'.".format(f.__name__, value)) - raise exc from e - - wrapper._raises_config_error = True - return wrapper diff --git a/pyproject.toml b/pyproject.toml index e57fcc1..1cc55aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "iubeo" -version = "0.2.2" +version = "0.3.0" description = "Friendlier way to write your config." authors = ["isik-kaplan "] diff --git a/tests/test_iubeo_casters.py b/tests/test_iubeo_casters.py deleted file mode 100644 index c884646..0000000 --- a/tests/test_iubeo_casters.py +++ /dev/null @@ -1,57 +0,0 @@ -from unittest import TestCase - -from iubeo import ConfigError, boolean, comma_separated_list -from iubeo.utils import raise_config_error_instead - - -class IubeoCastersTestCase(TestCase): - comma_separated_list_tests = [ - "a,b,c", - "a,,b,,c", - ",a,b,c", - ",a,b,c,", - ] - - comma_separated_list_test_results = [ - ["a", "b", "c"], - ["a", "", "b", "", "c"], - ["", "a", "b", "c"], - ["", "a", "b", "c", ""], - ] - - boolean_tests = [ - "False", - "false", - "0", - "True", - "true", - "1", - ] - - boolean_test_results = [ - False, - False, - False, - True, - True, - True, - ] - - def test_raise_config_instead(self): - @raise_config_error_instead - def faulty_function(value): - raise Exception("test_raise_config_instead") - - with self.assertRaises(ConfigError): - faulty_function(None) - - def test_comma_separated_list(self): - for _in, _out in zip(self.comma_separated_list_tests, self.comma_separated_list_test_results): - self.assertEqual(comma_separated_list(_in), _out) - - def test_boolean(self): - for _in, _out in zip(self.boolean_tests, self.boolean_test_results): - self.assertEqual(boolean(_in), _out) - - with self.assertRaises(ConfigError): - boolean("incorrect") diff --git a/tests/test_iubeo_config.py b/tests/test_iubeo_config.py index db66925..cac508d 100644 --- a/tests/test_iubeo_config.py +++ b/tests/test_iubeo_config.py @@ -1,6 +1,7 @@ +import os from unittest import TestCase -from iubeo import config +from iubeo import ConfigError, boolean, caster, comma_separated_int_list, comma_separated_list, config, integer, string def custom_callable(value): @@ -26,140 +27,161 @@ class IubeoConfigTestCase(TestCase): "N210": str, }, }, + "N3": comma_separated_list(), + "N4": comma_separated_list(missing_default=["1", "2", "3"]), + "N5": boolean(error_default=True), + "N6": comma_separated_int_list(missing_default=[1, 2, 3]), } - test_1_node_types = { - "N0": custom_callable, - "N1__N10": str, - "N1__N11__N110": int, - "N2__N20__N201__N2010": float, - "N2__N21__N210": str, - } - - test_1_state = { - "N0": ["N0", custom_callable], - "N1": { - "N10": ["N1__N00", str], - "N11": { - "N000": ["N1__N11__N000", int], - }, - }, - "N2": { - "N20": { - "N201": { - "N2010": ["N2__N20__N201__N2010", float], - } - }, - "N21": { - "N210": ["N2__N21__N210", str], - }, - }, - } - - test_1_node_types_prefix = { - "PREFIX__N0": custom_callable, - "PREFIX__N1__N10": str, - "PREFIX__N1__N11__N110": int, - "PREFIX__N2__N20__N201__N2010": float, - "PREFIX__N2__N21__N210": str, - } - - test_1_state_prefix = { - "N0": ["PREFIX__N0", custom_callable], - "N1": { - "N00": ["PREFIX__N1__N00", str], - "N11": { - "N000": ["PREFIX__N1__N11__N000", int], - }, - }, - "N2": { - "N20": { - "N201": { - "N2010": ["PREFIX__N2__N20__N201__N2010", float], - } - }, - "N21": { - "N210": ["PREFIX__N2__N21__N210", str], - }, - }, - } - - test_1_node_types_sep = { - "N0": custom_callable, - "N1_sep_N10": str, - "N1_sep_N11_sep_N110": int, - "N2_sep_N20_sep_N201_sep_N2010": float, - "N2_sep_N21_sep_N210": str, - } - - test_1_state_sep = { - "N0": ["N0", custom_callable], - "N1": { - "N00": ["N1_sep_N00", str], - "N11": { - "N000": ["N1_sep_N11_sep_N000", int], - }, - }, - "N2": { - "N20": { - "N201": { - "N2010": ["N2_sep_N20_sep_N201_sep_N2010", float], - } - }, - "N21": { - "N210": ["N2_sep_N21_sep_N210", str], - }, - }, - } - - test_1_node_types_prefix_sep = { - "PREFIX_sep_N0": custom_callable, - "PREFIX_sep_N1_sep_N10": str, - "PREFIX_sep_N1_sep_N11_sep_N110": int, - "PREFIX_sep_N2_sep_N20_sep_N201_sep_N2010": float, - "PREFIX_sep_N2_sep_N21_sep_N210": str, - } - - test_1_state_prefix_sep = { - "N0": ["PREFIX_sep_N0", custom_callable], - "N1": { - "N10": ["PREFIX_sep_N1_sep_N10", str], - "N11": { - "N000": ["PREFIX_sep_N1_sep_N11_sep_N000", int], - }, - }, - "N2": { - "N20": { - "N201": { - "N2010": ["PREFIX_sep_N2_sep_N20_sep_N201_sep_N2010", float], - } - }, - "N21": { - "N210": ["PREFIX_sep_N2_sep_N21_sep_N210", str], - }, - }, - } - - def setUp(self) -> None: - self.config = config(self.test_1) - self.config_prefix = config(self.test_1, prefix="PREFIX") - self.config_sep = config(self.test_1, sep="_sep_") - self.config_prefix_sep = config(self.test_1, prefix="PREFIX", sep="_sep_") - - def test_creates_correct_node_names(self): - self.assertEqual([*self.test_1_node_types.keys()], self.config.final_nodes) - - def test_creates_correct_node_names_with_prefix(self): - self.assertEqual([*self.test_1_node_types_prefix.keys()], self.config_prefix.final_nodes) - - def test_create_correct_node_names_with_sep(self): - self.assertEqual([*self.test_1_node_types_sep.keys()], self.config_sep.final_nodes) - - def test_create_correct_nodes_names_with_prefix_sep(self): - self.assertEqual([*self.test_1_node_types_prefix_sep.keys()], self.config_prefix_sep.final_nodes) - - def test_casts_the_callable(self): - self.assertEqual(self.config.N0, "CUSTOM_CALLABLE") - - def test_correctly_casts_the_values(self): - self.assertEqual(self.config.N0, "CUSTOM_CALLABLE") + def setupTest1(self, prefix=None, sep=None): + prefix = prefix or "" + sep = sep or "__" + expected_variables = { + sep.join([i for i in [prefix, "N0"] if i]): custom_callable(None), + sep.join([i for i in [prefix, "N1", "N10"] if i]): "Test1", + sep.join([i for i in [prefix, "N1", "N11", "N110"] if i]): "1", + sep.join([i for i in [prefix, "N2", "N20", "N201", "N2010"] if i]): "0.1", + sep.join([i for i in [prefix, "N2", "N21", "N210"] if i]): "Test2", + sep.join([i for i in [prefix, "N3"] if i]): "1,2,3", + sep.join([i for i in [prefix, "N5"] if i]): "no", + } + for key, value in expected_variables.items(): + os.environ.setdefault(key, value) + + def teardown(): + for k in expected_variables.keys(): + os.environ.pop(k) + + return teardown + + def setUp(self): + self.test_key = "KEY" + + def tearDown(self): + os.environ.pop(self.test_key, None) + + def assertTest1(self, test_config): + assert test_config.N0 == "CUSTOM_CALLABLE" + assert test_config.N1.N10 == "Test1" + assert test_config.N1.N11.N110 == 1 + assert test_config.N2.N20.N201.N2010 == 0.1 + assert test_config.N2.N21.N210 == "Test2" + assert test_config.N3 == ["1", "2", "3"] + assert test_config.N4 == ["1", "2", "3"] + assert test_config.N5 == True + assert test_config.N6 == [1, 2, 3] + + def test_config_created_successfully(self): + teardown = self.setupTest1() + test_config = config(self.test_1) + + self.assertTest1(test_config) + teardown() + + def test_config_separator(self): + teardown = self.setupTest1(sep=".") + test_config = config(self.test_1, sep=".") + + self.assertTest1(test_config) + teardown() + + def test_config_prefix(self): + teardown = self.setupTest1(prefix="PREFIX") + test_config = config(self.test_1, prefix="PREFIX") + + self.assertTest1(test_config) + teardown() + + def test_config_prefix_and_separator(self): + teardown = self.setupTest1(prefix="PREFIX", sep=".") + test_config = config(self.test_1, prefix="PREFIX", sep=".") + + self.assertTest1(test_config) + teardown() + + def test_caster__boolean__success(self): + os.environ.setdefault(self.test_key, "True") + test_config = config({self.test_key: boolean()}) + assert test_config.KEY == True + + def test_caster__boolean__cant_parse(self): + os.environ.setdefault(self.test_key, "no") + with self.assertRaises(ConfigError): + config({self.test_key: boolean()}) + test_config = config({self.test_key: boolean(error_default=True)}) + assert test_config.KEY == True + + def test_caster__boolean__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: boolean()}) + test_config = config({self.test_key: boolean(missing_default=True)}) + assert test_config.KEY == True + + def test_caster__comma_separated_list__success(self): + os.environ.setdefault(self.test_key, "1,2,3") + test_config = config({self.test_key: comma_separated_list()}) + assert test_config.KEY == ["1", "2", "3"] + + def test_caster__comma_separated_list__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: comma_separated_list()}) + test_config = config({self.test_key: comma_separated_list(missing_default=["1", "2", "3"])}) + assert test_config.KEY == ["1", "2", "3"] + + def test_caster__comma_separated_int_list__success(self): + os.environ.setdefault(self.test_key, "1,2,3") + test_config = config({self.test_key: comma_separated_int_list()}) + assert test_config.KEY == [1, 2, 3] + + def test_caster__comma_separated_int_list__cant_parse(self): + os.environ.setdefault(self.test_key, "1,2,no") + with self.assertRaises(ConfigError): + config({self.test_key: comma_separated_int_list()}) + test_config = config({self.test_key: comma_separated_int_list(error_default=[1, 2, 3])}) + assert test_config.KEY == [1, 2, 3] + + def test_caster__comma_separated_int_list__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: comma_separated_int_list()}) + test_config = config({self.test_key: comma_separated_int_list(missing_default=[1, 2, 3])}) + assert test_config.KEY == [1, 2, 3] + + def test_caster__string__success(self): + os.environ.setdefault(self.test_key, "Test") + test_config = config({self.test_key: string()}) + assert test_config.KEY == "Test" + + def test_caster__string__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: string()}) + test_config = config({self.test_key: string(missing_default="Test")}) + assert test_config.KEY == "Test" + + def test_caster__integer__success(self): + os.environ.setdefault(self.test_key, "1") + test_config = config({self.test_key: integer()}) + assert test_config.KEY == 1 + + def test_caster__integer__cant_parse(self): + os.environ.setdefault(self.test_key, "no") + with self.assertRaises(ConfigError): + config({self.test_key: integer()}) + test_config = config({self.test_key: integer(error_default=1)}) + assert test_config.KEY == 1 + + def test_caster__integer__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: integer()}) + test_config = config({self.test_key: integer(missing_default=1)}) + assert test_config.KEY == 1 + + def test_caster__custom_callable__success(self): + os.environ.setdefault(self.test_key, "Test") + test_config = config({self.test_key: custom_callable}) + assert test_config.KEY == "CUSTOM_CALLABLE" + + def test_caster__custom_callable__missing(self): + with self.assertRaises(ConfigError): + config({self.test_key: caster(custom_callable)}) + test_config = config({self.test_key: caster(custom_callable)(missing_default="CUSTOM_CALLABLE")}) + assert test_config.KEY == "CUSTOM_CALLABLE" diff --git a/tests/test_iubeo_utils.py b/tests/test_iubeo_utils.py deleted file mode 100644 index 9b74bee..0000000 --- a/tests/test_iubeo_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from unittest import TestCase - -from iubeo.utils import raise_config_error_instead - - -@raise_config_error_instead -def custom_callable(value): - return "CUSTOM_CALLABLE" - - -class IubeoUtilsTestCase(TestCase): - def test_raises_config_error(self): - self.assertTrue(hasattr(custom_callable, "_raises_config_error") and custom_callable._raises_config_error)