Skip to content
Merged
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
22 changes: 10 additions & 12 deletions src/dataclass_binder/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,24 +618,22 @@ def format_template(class_or_instance: Any) -> Iterator[str]:
def _format_value_for_field(config_class: type[Any], field: Field) -> str:
"""Format an example value or placeholder for a value depending on the given field's type."""

field_type: type[Any] | str | None = field.type
if isinstance(field_type, str):
annotated_type: type[Any] | str | None = field.type
if isinstance(annotated_type, str):
module_locals = {}
module = getmodule(config_class)
if module is not None:
for name in dir(module):
module_locals[name] = getattr(module, name)
try:
evaluated_type = eval(field_type, globals(), module_locals) # noqa: PGH001
evaluated_type = eval(annotated_type, globals(), module_locals) # noqa: PGH001
except Exception:
field_type = None
else:
field_type = _collect_type(evaluated_type, f"{config_class.__name__}.{field.name}")

if field_type is None:
return "???"
return "???"
else:
return _format_value_for_type(field_type)
evaluated_type = annotated_type

field_type = _collect_type(evaluated_type, f"{config_class.__name__}.{field.name}")
return _format_value_for_type(field_type)


def _format_value_for_type(field_type: type[Any]) -> str:
Expand All @@ -657,8 +655,8 @@ def _format_value_for_type(field_type: type[Any]) -> str:
return "2020-01-01"
elif field_type is time or field_type is timedelta:
return "00:00:00"
elif is_dataclass(field_type):
return "".join(_format_fields_inline(field_type))
elif issubclass(field_type, Binder):
return "".join(_format_fields_inline(field_type._get_config_class())) # noqa: SLF001
else:
# We have handled all the non-generic types supported by _collect_type().
raise AssertionError(field_type)
Expand Down
95 changes: 69 additions & 26 deletions tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from io import BytesIO
from types import ModuleType
from typing import Any, TypeVar, cast
from types import ModuleType, NoneType, UnionType
from typing import Any, TypeVar, cast, get_args, get_origin

import pytest

Expand All @@ -16,10 +16,34 @@
T = TypeVar("T")


def single_value_dataclass(annotation: type[Any]) -> type[Any]:
def format_annotation(annotation: object) -> str:
origin = get_origin(annotation)
if origin is None:
if annotation is NoneType:
return "None"
elif annotation is ModuleType:
return "ModuleType"
elif isinstance(annotation, type):
return annotation.__name__
else:
raise AssertionError(annotation)
elif origin is UnionType:
return " | ".join(format_annotation(arg) for arg in get_args(annotation))
else:
return f"{origin.__name__}[{', '.join(format_annotation(arg) for arg in get_args(annotation))}]"


def single_value_dataclass(value_type: type[Any], *, optional: bool = False, string: bool = False) -> type[Any]:
annotation = value_type | None if optional else value_type
if string:
annotation = format_annotation(annotation)

@dataclass
class DC:
value: object
if optional:
value: object = None
else:
value: object # type: ignore[no-redef]
__annotations__["value"] = annotation

return DC
Expand Down Expand Up @@ -91,30 +115,38 @@ def round_trip_value(value: T, dc: type[Any]) -> T:
example,
),
)
def test_format_value_round_trip(value: object) -> None:
dc = single_value_dataclass(type(value))
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_round_trip(*, value: object, optional: bool, string: bool) -> None:
dc = single_value_dataclass(type(value), optional=optional, string=string)
assert round_trip_value(value, dc) == value


def test_format_value_class() -> None:
dc = single_value_dataclass(type)
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_class(*, optional: bool, string: bool) -> None:
dc = single_value_dataclass(type, optional=optional, string=string)
assert round_trip_value(example.Config, dc) is example.Config


def test_format_value_list_simple() -> None:
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_list_simple(*, optional: bool, string: bool) -> None:
"""A sequence is formatted as a TOML array."""
value = [1, 2, 3]
dc = single_value_dataclass(list[int])
dc = single_value_dataclass(list[int], optional=optional, string=string)
assert round_trip_value(value, dc) == value


def test_format_value_list_suffix() -> None:
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_list_suffix(*, optional: bool, string: bool) -> None:
"""
It is an error to use a value that requires a suffix in a sequence.

TODO: Reconsider the design decision to use key suffixes, as it leads to this gap in expressiveness.
"""
dc = single_value_dataclass(list[timedelta])
dc = single_value_dataclass(list[timedelta], optional=optional, string=string)
assert round_trip_value([], dc) == []
assert round_trip_value([timedelta(hours=2)], dc) == [timedelta(hours=2)]
with pytest.raises(
Expand All @@ -123,13 +155,15 @@ def test_format_value_list_suffix() -> None:
round_trip_value([timedelta(days=2)], dc)


def test_format_value_dict() -> None:
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_dict(*, optional: bool, string: bool) -> None:
"""
A mapping is formatted as a TOML inline table.

Bare keys are used where possible, otherwise quoted keys.
"""
dc = single_value_dataclass(dict[str, int])
dc = single_value_dataclass(dict[str, int], optional=optional, string=string)
value = {"a": 1, "b": 2, "c": 3}
assert format_toml_pair("value", value) == "value = {a = 1, b = 2, c = 3}"
assert round_trip_value(value, dc) == value
Expand All @@ -140,28 +174,33 @@ def test_format_value_dict() -> None:
assert round_trip_value(value, dc) == value


def test_format_value_dict_suffix() -> None:
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_dict_suffix(*, optional: bool, string: bool) -> None:
"""
Values that require a suffix can be used in a mapping.

TODO: Actually, in our current implementation they cannot.
I don't want to spend time fixing this though if we might throw out the entire suffix mechanism;
see test_format_value_list_suffix() for details.
"""
dc = single_value_dataclass(dict[str, timedelta])
dc = single_value_dataclass(dict[str, timedelta], optional=optional, string=string)
assert round_trip_value({}, dc) == {}
assert round_trip_value({"delay": timedelta(hours=2)}, dc) == {"delay": timedelta(hours=2)}
# assert round_trip({"delay": timedelta(days=2)}, dc) == {"delay": timedelta(days=2)} # noqa: ERA001
assert format_toml_pair("value", {"delay": timedelta(days=2)}) == "value = {delay-days = 2}"


def test_format_value_nested_dataclass() -> None:
@dataclass(kw_only=True)
class Inner:
key_containing_underscores: bool
maybesuffix: timedelta
@dataclass(kw_only=True)
class Inner:
key_containing_underscores: bool
maybesuffix: timedelta


dc = single_value_dataclass(Inner)
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_value_nested_dataclass(*, optional: bool, string: bool) -> None:
dc = single_value_dataclass(Inner, optional=optional, string=string)
value = Inner(key_containing_underscores=True, maybesuffix=timedelta(days=2))
assert round_trip_value(value, dc) == value

Expand All @@ -181,8 +220,10 @@ def test_docstring_extraction_example() -> None:
}


def test_docstring_extraction_indented() -> None:
dc = single_value_dataclass(int)
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_docstring_extraction_indented(*, optional: bool, string: bool) -> None:
dc = single_value_dataclass(int, optional=optional, string=string)
docstrings = get_field_docstrings(dc)
assert docstrings == {}

Expand Down Expand Up @@ -272,13 +313,15 @@ class NestedConfig:
@pytest.mark.parametrize(
"field_type", (str, int, float, datetime, date, time, timedelta, list[str], dict[str, int], NestedConfig)
)
def test_format_template_valid_value(field_type: type[Any]) -> None:
@pytest.mark.parametrize("optional", (True, False))
@pytest.mark.parametrize("string", (True, False))
def test_format_template_valid_value(*, field_type: type[Any], optional: bool, string: bool) -> None:
"""
The template generated for the given field type is valid TOML and the value has the right type.

Not all templates values are valid TOML, but the selected parameters are.
"""
dc = single_value_dataclass(field_type)
dc = single_value_dataclass(field_type, optional=optional, string=string)
toml = "\n".join(format_template(dc))
print(field_type, "->", toml) # noqa: T201
parse_toml(dc, toml)
Expand Down