Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion confidence/formats.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing this needs a test

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could so something like Format(strict=False), where the fallback type is always str. Less explicit, maybe easier to understand?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realistically, what do we expect this callable to be/do? Are there many more sane options than str? Do we expect the behavior to change per format?

I'm thinking this might be too much flexibility for our needs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, str is the one and only sane option I can think of at the moment, really. So that actually doesn't need to be parametrized. The behaviour wouldn't need to change per format, for so far as the formats behave the same way (as in: YAML doesn't really need these hoops as it's (too?) flexible in itself).

In hindsight, strict might not be a great fit either though, which begs the question: should this feature (if we go through with it) even get a switch or be enabled by default?


def load(self, fp: typing.TextIO) -> typing.Any:
return self.loads(fp.read())
Expand All @@ -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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this implementation is entirely kosher, thoughts welcome 🤔

This combined with calling it where singular values are expected does actually fairly transparently solve the issue.

@ranieri ranieri Aug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on the formats doing the proper translating of all possible relevant parsing errors back to ValueError, and there not being any extraneous ValueErrors.

I hate to bring this up, but could this be a good place for a custom exception type?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only ones not supporting the fallback themselves are JSON and TOML, both using a fairly sane exception hierachy where a parsing failure will raise something that quacks ValueError 😎 Not sure a custom type will add anything there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I was led astray by the complex Exception hierarchy of PyYAML (see for example yaml/pyyaml#750). If the parsers that need it raise ValueErrors, that this should work.

It feels a bit ad-hoc though. If exception types are a part of the Format API, should it be documented so people adding a format can make sure it throws ValueErrors in the cases covered by this fallback?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, documentation of the assumptions here makes sense, let's at least make that clear.

  • document the role of ValueError in Format.loadv

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))

Expand Down
4 changes: 2 additions & 2 deletions confidence/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 45 additions & 2 deletions tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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',
(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading