Consistent component return type

This commit is contained in:
2026-05-12 08:36:51 +02:00
parent ff11e35115
commit 8c3e819a5f
3 changed files with 67 additions and 16 deletions
+2 -5
View File
@@ -15,11 +15,8 @@
- `games/templatetags/randomid.py` uses the same hash-based approach - `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety - Fixes: caching (Popover output now cacheable), page consistency, thread safety
### 1. Inconsistent return types ### 1. Inconsistent return types (completed)
`Div()`/`A()`/`Button()` return `str`, but `LinkedPurchase()`/`NameWithIcon()` return `SafeText`. All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
Forces callers to remember `mark_safe()` wrapping.
**Fix**: Standardize — all component functions should return the same type.
### 2. Fragile A() URL resolution ### 2. Fragile A() URL resolution
Tries `reverse(url)` first, then falls back to literal string. Uses `type(url) is str` Tries `reverse(url)` first, then falls back to literal string. Uses `type(url) is str`
+11 -11
View File
@@ -40,7 +40,7 @@ def Component(
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
template: str = "", template: str = "",
tag_name: str = "", tag_name: str = "",
) -> HTMLTag: ) -> SafeText:
if not tag_name and not template: if not tag_name and not template:
raise ValueError("One of template or tag_name is required.") raise ValueError("One of template or tag_name is required.")
if isinstance(children, str): if isinstance(children, str):
@@ -130,7 +130,7 @@ def A(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "", url: str | Callable[..., Any] = "",
): ) -> SafeText:
""" """
Returns the HTML tag "a". Returns the HTML tag "a".
"url" can either be: "url" can either be:
@@ -161,7 +161,7 @@ def Button(
size: str = "base", size: str = "base",
icon: bool = False, icon: bool = False,
color: str = "blue", color: str = "blue",
): ) -> SafeText:
return Component( return Component(
template="cotton/button.html", template="cotton/button.html",
attributes=attributes attributes=attributes
@@ -178,7 +178,7 @@ def Button(
def Div( def Div(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
): ) -> SafeText:
return Component(tag_name="div", attributes=attributes, children=children) return Component(tag_name="div", attributes=attributes, children=children)
@@ -186,7 +186,7 @@ def Input(
type: str = "text", type: str = "text",
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
): ) -> SafeText:
return Component( return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children tag_name="input", attributes=attributes + [("type", type)], children=children
) )
@@ -197,7 +197,7 @@ def Form(
method="get", method="get",
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag = [],
): ) -> SafeText:
return Component( return Component(
tag_name="form", tag_name="form",
attributes=attributes + [("action", action), ("method", method)], attributes=attributes + [("action", action), ("method", method)],
@@ -208,7 +208,7 @@ def Form(
def Icon( def Icon(
name: str, name: str,
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
): ) -> SafeText:
try: try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes) result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist: except TemplateDoesNotExist:
@@ -254,7 +254,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
), ),
], ],
) )
return mark_safe(A(url=link, children=[a_content])) return A(url=link, children=[a_content])
def NameWithIcon( def NameWithIcon(
@@ -297,17 +297,17 @@ def NameWithIcon(
], ],
) )
return mark_safe( return (
A( A(
url=link, url=link,
children=[content], children=[content],
) )
if create_link if create_link
else content, else content
) )
def PurchasePrice(purchase) -> str: def PurchasePrice(purchase) -> SafeText:
return Popover( return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}", popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}", wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
+54
View File
@@ -346,5 +346,59 @@ class TemplatetagRandomidTest(unittest.TestCase):
self.assertTrue(all(c in "abcdef0123456789" for c in result)) self.assertTrue(all(c in "abcdef0123456789" for c in result))
class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello")
self.assertIsInstance(result, SafeText)
def test_div_deterministic(self):
r1 = components.Div([("class", "x")], "hello")
r2 = components.Div([("class", "x")], "hello")
self.assertEqual(r1, r2)
self.assertIn('<div class="x">hello</div>', r1)
def test_div_no_args(self):
result = components.Div(children="test")
self.assertIsInstance(result, SafeText)
self.assertIn('<div>test</div>', result)
def test_a_returns_safe_text(self):
result = components.A([], "link")
self.assertIsInstance(result, SafeText)
def test_a_literal_href(self):
result = components.A([], "x", url="/literal/path")
self.assertIn('href="/literal/path"', result)
def test_button_returns_safe_text(self):
result = components.Button([], "click")
self.assertIsInstance(result, SafeText)
self.assertIn("<button", result)
def test_button_default_colors(self):
result = components.Button([], "click")
self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self):
result = components.NameWithIcon(name="Game", platform="Steam", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertIn("Game", result)
self.assertNotIn("<a ", result)
def test_name_with_icon_no_trailing_comma(self):
result = components.NameWithIcon(name="Test", platform="Steam", linkify=False)
self.assertIsInstance(result, SafeText)
self.assertNotIsInstance(result, tuple)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()