Skip to content

Commit 1cee43a

Browse files
authored
Merge pull request #60 from volfpeter/feat/complex-value-formatting
feat: add support for complex values to Formatter, fixes #59
2 parents 0d4f908 + 872ca22 commit 1cee43a

File tree

5 files changed

+72
-12
lines changed

5 files changed

+72
-12
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ These are default tag attribute formatting rules:
266266
- `bool` attribute values are converted to strings (`"true"` and `"false"`).
267267
- `XBool.true` attributes values are converted to an empty string, and `XBool.false` values are skipped (only the attribute name is rendered).
268268
- `date` and `datetime` attribute values are converted to ISO strings.
269+
- Complex values such as lists, dictionaries, tuples, and sets are JSON serialized.
269270

270271
### Error boundary
271272

docs/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ These are default tag attribute formatting rules:
266266
- `bool` attribute values are converted to strings (`"true"` and `"false"`).
267267
- `XBool.true` attributes values are converted to an empty string, and `XBool.false` values are skipped (only the attribute name is rendered).
268268
- `date` and `datetime` attribute values are converted to ISO strings.
269+
- Complex values such as lists, dictionaries, tuples, and sets are JSON serialized.
269270

270271
### Error boundary
271272

htmy/core.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import abc
44
import enum
5+
import json
56
from collections.abc import Callable, Container
67
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
78
from xml.sax.saxutils import escape as xml_escape
@@ -251,14 +252,17 @@ class Formatter(ContextAware):
251252
"""
252253
The default, context-aware property name and value formatter.
253254
255+
The formatter supports both primitive and (many) complex values, such as lists,
256+
dictionaries, tuples, and sets. Complex values are JSON-serialized by default.
257+
254258
Important: the default implementation looks up the formatter for a given value by checking
255259
its type, but it doesn't do this check with the base classes of the encountered type. For
256260
example the formatter will know how to format `datetime` object, but it won't know how to
257261
format a `MyCustomDatetime(datetime)` instance.
258262
259263
One reason for this is efficiency: always checking the base classes of every single value is a
260264
lot of unnecessary calculation. The other reason is customizability: this way you could use
261-
subclassing for fomatter selection, e.g. with `LocaleDatetime(datetime)`-like classes.
265+
subclassing for formatter selection, e.g. with `LocaleDatetime(datetime)`-like classes.
262266
263267
Property name and value formatters may raise a `SkipProperty` error if a property should be skipped.
264268
"""
@@ -337,6 +341,10 @@ def _base_formatters(self) -> dict[type, Callable[[Any], str]]:
337341
bool: lambda v: "true" if v else "false",
338342
date: lambda d: cast(date, d).isoformat(),
339343
datetime: lambda d: cast(datetime, d).isoformat(),
344+
dict: lambda v: json.dumps(v),
345+
list: lambda v: json.dumps(v),
346+
tuple: lambda v: json.dumps(v),
347+
set: lambda v: json.dumps(tuple(v)),
340348
XBool: lambda v: cast(XBool, v).format(),
341349
type(None): SkipProperty.format_property,
342350
}

pyproject.toml

+11-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "htmy"
3-
version = "0.7.1"
3+
version = "0.7.2"
44
description = "Async, pure-Python rendering engine."
55
authors = ["Peter Volf <do.volfp@gmail.com>"]
66
license = "MIT"
@@ -14,7 +14,7 @@ markdown = "^3.7"
1414

1515
[tool.poetry.group.dev.dependencies]
1616
mkdocs-material = "^9.5.39"
17-
mkdocstrings = {extras = ["python"], version = "^0.26.1"}
17+
mkdocstrings = { extras = ["python"], version = "^0.26.1" }
1818
mypy = "^1.15.0"
1919
poethepoet = "^0.29.0"
2020
pytest = "^8.3.3"
@@ -49,17 +49,17 @@ exclude = [
4949
"docs",
5050
]
5151
lint.select = [
52-
"B", # flake8-bugbear
53-
"C", # flake8-comprehensions
54-
"E", # pycodestyle errors
55-
"F", # pyflakes
56-
"I", # isort
57-
"S", # flake8-bandit - we must ignore these rules in tests
58-
"W", # pycodestyle warnings
52+
"B", # flake8-bugbear
53+
"C", # flake8-comprehensions
54+
"E", # pycodestyle errors
55+
"F", # pyflakes
56+
"I", # isort
57+
"S", # flake8-bandit - we must ignore these rules in tests
58+
"W", # pycodestyle warnings
5959
]
6060

6161
[tool.ruff.lint.per-file-ignores]
62-
"tests/**/*" = ["S101"] # S101: use of assert detected
62+
"tests/**/*" = ["S101"] # S101: use of assert detected
6363

6464
[tool.poe.tasks]
6565
check-format = "ruff format --check ."
@@ -70,4 +70,4 @@ mypy = "mypy ."
7070
test = "python -m pytest tests"
7171
static-checks.sequence = ["lint", "check-format", "mypy"]
7272
static-checks.ignore_fail = "return_non_zero"
73-
serve-docs = "mkdocs serve"
73+
serve-docs = "mkdocs serve"

tests/test_formatter.py

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import Any
2+
3+
import pytest
4+
5+
from htmy import Formatter
6+
7+
8+
@pytest.fixture
9+
def formatter() -> Formatter:
10+
return Formatter()
11+
12+
13+
@pytest.mark.parametrize(
14+
("data", "formatted_value"),
15+
(
16+
({}, "{}"),
17+
([], "[]"),
18+
((), "[]"),
19+
(set(), "[]"),
20+
(
21+
{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}},
22+
'{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}',
23+
),
24+
(["string", 3.14, {"key": "value"}], '["string", 3.14, {"key": "value"}]'),
25+
(("string", 3.14, {"key": "value"}), '["string", 3.14, {"key": "value"}]'),
26+
({"c0ff33"}, '["c0ff33"]'),
27+
),
28+
)
29+
def test_complex_value_formatting(data: Any, formatted_value: str, formatter: Formatter) -> None:
30+
assert formatter.format_value(data) == formatted_value
31+
32+
33+
@pytest.mark.parametrize(
34+
("data", "formatted_value"),
35+
(
36+
({}, '"{}"'),
37+
([], '"[]"'),
38+
((), '"[]"'),
39+
(set(), '"[]"'),
40+
(
41+
{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}},
42+
'\'{"drink": "coffee", "food": "pizza", "bill": {"net": 100, "vat": 20, "total": 120}}\'',
43+
),
44+
(["string", 3.14, {"key": "value"}], '\'["string", 3.14, {"key": "value"}]\''),
45+
(("string", 3.14, {"key": "value"}), '\'["string", 3.14, {"key": "value"}]\''),
46+
({"c0ff33"}, "'[\"c0ff33\"]'"),
47+
),
48+
)
49+
def test_complex_property_formatting(data: Any, formatted_value: str, formatter: Formatter) -> None:
50+
assert formatter.format("property", data) == f"property={formatted_value}"

0 commit comments

Comments
 (0)