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
72 changes: 44 additions & 28 deletions src/dataclass_binder/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
import operator
import re
import sys
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableMapping, MutableSequence, Sequence
from collections.abc import (
Callable,
Collection,
Iterable,
Iterator,
Mapping,
MutableMapping,
MutableSequence,
Sequence,
Set,
)
from dataclasses import MISSING, asdict, dataclass, fields, is_dataclass, replace
from datetime import date, datetime, time, timedelta
from functools import reduce
Expand Down Expand Up @@ -200,14 +210,15 @@ def get(cls, dataclass: type[T]) -> _ClassInfo[T]:
try:
return cls._cache[dataclass]
except KeyError:
info = cls(
dataclass,
{
field_name: _collect_type(field_type, f"{dataclass.__name__}.{field_name}")
for field_name, field_type in _get_fields(dataclass)
},
)
# Populate field_types *after* adding new instance to the cache to make sure
# _collect_type() will find the given dataclass if it's accessed recursively.
field_types: dict[str, type | Binder[Any]] = {}
info = cls(dataclass, field_types)
cls._cache[dataclass] = info
field_types.update(
(field_name, _collect_type(field_type, f"{dataclass.__name__}.{field_name}"))
for field_name, field_type in _get_fields(dataclass)
)
return info

@property
Expand Down Expand Up @@ -418,20 +429,13 @@ def format_toml_template(self) -> Iterator[str]:
If we are binding to a class, example values will be derived from the field types.
"""

queue: list[Table[Any]] = [Table(self, "", self._instance, None)]

def defer(table: Table[Any]) -> None:
queue.append(table.prefix_context(context))

skip_empty = True
while queue:
table = queue.pop(0)
context = table.key_fmt
for line in table.format_table(defer):
if not line and skip_empty:
skip_empty = False
continue
table = Table(self, "", self._instance, None)
lines = table.format_table(set())
for line in lines: # pragma: no cover
if line:
yield line
break
yield from lines

def _format_toml_table(self, instance: T | None, defer: Callable[[Table[Any]], None]) -> Iterator[str]:
dataclass = self._dataclass
Expand Down Expand Up @@ -548,24 +552,36 @@ def class_docstring(self) -> str | None:
def prefix_context(self, context: str) -> Table[T]:
return replace(self, key_fmt=f"{context}.{self.key_fmt}" if context else self.key_fmt)

def format_table(self, defer: Callable[[Table[Any]], None]) -> Iterator[str]:
def format_table(self, inside: Set[type]) -> Iterator[str]:
child_tables: list[Table[Any]] = []
context = self.key_fmt
value = self.value

if (binder := self.binder) is None:
match self.value:
match value:
case Mapping() as mapping:
content = [format_toml_pair(k, v) for k, v in mapping.items()]
case dc if is_dataclass(dc):
content = [format_toml_pair(k, v) for k, v in asdict(dc).items()] # type: ignore[arg-type]
case _:
content = []
elif value is None and binder._dataclass in inside:
# Prevent infinite recursion.
content = None
else:
content = list(binder._format_toml_table(self.value, defer))
inside |= {binder._dataclass}
content = list(binder._format_toml_table(value, child_tables.append))
if not content:
return
content = None

if content is not None:
if context:
yield from self.format_header()

if self.key_fmt:
yield from self.format_header()
yield from content

yield from content
for table in child_tables:
yield from table.prefix_context(context).format_table(inside)

def format_header(self) -> Iterator[str]:
yield ""
Expand Down
60 changes: 60 additions & 0 deletions tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,66 @@ def test_format_template_populated() -> None:
)


@dataclass
class BinaryTree:
value: int
left: BinaryTree | None = None
right: BinaryTree | None = None


def test_format_template_depth_first() -> None:
"""Tables are formatted depth-first: children before siblings."""

tree = BinaryTree(
value=1,
left=BinaryTree(value=2, left=BinaryTree(value=3), right=BinaryTree(value=4)),
right=BinaryTree(value=5, left=BinaryTree(value=6), right=BinaryTree(value=7)),
)
template = "\n".join(Binder(tree).format_toml_template())
assert template == (
"""
# Mandatory.
value = 1

# Optional table.
[left]

# Mandatory.
value = 2

# Optional table.
[left.left]

# Mandatory.
value = 3

# Optional table.
[left.right]

# Mandatory.
value = 4

# Optional table.
[right]

# Mandatory.
value = 5

# Optional table.
[right.left]

# Mandatory.
value = 6

# Optional table.
[right.right]

# Mandatory.
value = 7
""".strip()
)


@pytest.fixture()
def sourceless_class() -> type[Any]:
"""A class for which no source code is available."""
Expand Down