diff --git a/confidence/formats.py b/confidence/formats.py index 7566f79..4c9a2fa 100644 --- a/confidence/formats.py +++ b/confidence/formats.py @@ -1,7 +1,7 @@ import json import typing from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from os import PathLike from pathlib import Path @@ -26,6 +26,7 @@ class Format(ABC): suffix: str = '' #: the default file path suffix for a configuration file of this Format encoding: str = 'utf-8' #: the default text encoding for reading from binary I/O + value_fallback: Callable[[str], typing.Any] = str #: the fallback 'factory' for unparseable single values def load(self, fp: typing.TextIO) -> typing.Any: return self.loads(fp.read()) @@ -38,6 +39,14 @@ def loadf(self, fpath: str | PathLike, encoding: str | None = None) -> typing.An with Path(fpath).open('rt', encoding=encoding or self.encoding) as fp: return self.load(fp) + def loadv(self, string: str) -> typing.Any: + try: + # hope the format implementation will be able to read string as a value formatted value + return self.loads(string) + except ValueError: + # use the fallback otherwise + return self.value_fallback(string) + def dump(self, value: typing.Any, fp: typing.TextIO) -> None: fp.write(self.dumps(value)) diff --git a/confidence/io.py b/confidence/io.py index f10a1b6..a6f4abe 100644 --- a/confidence/io.py +++ b/confidence/io.py @@ -101,8 +101,8 @@ def dotted(name: str) -> str: # include the number of variables matched for debugging purposes LOG.info(f'reading configuration from {len(values)} {prefix}* environment variables') - # pass value to yaml.safe_load to align data type transformation with reading values from files - return Configuration({dotted(name): format.loads(value) for name, value in values.items()}) + # pass value to format.loadv to align data type transformation with reading values from files + return Configuration({dotted(name): format.loadv(value) for name, value in values.items()}) def read_envvar_file(name: str, format: Format = YAML) -> Configuration: diff --git a/tests/test_formats.py b/tests/test_formats.py index 85f19f5..fc5507a 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -7,7 +7,42 @@ from confidence.formats import JSON, TOML, YAML -@pytest.mark.parametrize('format', (JSON, TOML, YAML)) +@pytest.mark.parametrize( + 'format', + ( + pytest.param(JSON, id='json'), + pytest.param(TOML, id='toml'), + pytest.param(YAML, id='yaml'), + ), +) +@pytest.mark.parametrize( + ('string', 'value'), + [ + ('null', None), + ('true', True), + ('1', 1), + ('42.0', 42.0), + ('a string', 'a string'), + ("single'quote", "single'quote"), + ('double"quote', 'double"quote'), + ], +) +def test_singular_value_from_string(format, string, value): + if (format, value) == (TOML, None): + # None / null / nil is not supported by the TOML spec, see https://github.com/toml-lang/toml/issues/30 + pytest.skip('None is unsupported for TOML format') + + assert format.loadv(string) == value + + +@pytest.mark.parametrize( + 'format', + ( + pytest.param(JSON, id='json'), + pytest.param(TOML, id='toml'), + pytest.param(YAML, id='yaml'), + ), +) @pytest.mark.parametrize('value', (None, True, 1, 42.0, 'a string')) def test_singular_value_roundtrip(format, value): if (format, value) == (TOML, None): @@ -17,7 +52,15 @@ def test_singular_value_roundtrip(format, value): assert format.loads(format.dumps(value)) == value -@pytest.mark.parametrize('format', (JSON, TOML, YAML, YAML(suffix='.conf', encoding='utf-32'))) +@pytest.mark.parametrize( + 'format', + ( + pytest.param(JSON, id='json'), + pytest.param(TOML, id='toml'), + pytest.param(YAML, id='yaml'), + pytest.param(YAML(suffix='.conf', encoding='utf-32'), id='yaml-conf-32'), + ), +) @pytest.mark.parametrize( 'value', ( diff --git a/tests/test_io.py b/tests/test_io.py index 8de5db9..c26f347 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -370,6 +370,35 @@ def test_load_name_envvars(): assert not load_name('foo', 'bar', load_order=(read_envvars,)) +@pytest.mark.parametrize( + 'format', + ( + pytest.param(JSON, id='json'), + pytest.param(TOML, id='toml'), + pytest.param(YAML, id='yaml'), + ), +) +def test_load_name_envvars_value_types(format): + env = { + 'FOO_KEY': 'foo', + 'FOO_TYPES_NUM': '42', + 'FOO_TYPES_FLT': '42.0', + 'FOO_TYPES_BOL': 'true', + 'FOO_TYPES_ST1': 'str', + 'FOO_TYPES_ST2': '"str"', + } + + with patch('confidence.io.environ', env): + config = load_name('foo', load_order=(read_envvars,), format=format) + + assert config.key == 'foo' + assert config.types.num == 42 + assert config.types.flt == 42.0 + assert config.types.bol == True # noqa: E712 (tomlkit uses a customized type that not is True but == True) + assert config.types.st1 == 'str' + assert config.types.st2 == 'str' + + def test_load_name_envvar_file(test_files): env = { 'FOO_CONFIG_FILE': path.join(test_files, 'foo.yaml'),