Skip to content

Commit

Permalink
feat(f-strings): Nest elements using f-string
Browse files Browse the repository at this point in the history
  • Loading branch information
paveldedik committed Jan 29, 2025
1 parent a9707c2 commit c2e9741
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 31 deletions.
5 changes: 4 additions & 1 deletion ludic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ class BaseElement(metaclass=ABCMeta):
void_element: ClassVar[bool] = False

formatter: ClassVar[FormatContext] = FormatContext("element_formatter")
formatter_fstring_wrap_in: ClassVar[type["BaseElement"] | None] = None

children: Sequence[Any]
attrs: Mapping[str, Any]
context: dict[str, Any]

def __init__(self, *children: Any, **attrs: Any) -> None:
self.context = {}
self.children = children
self.children = self.formatter.extract(
*children, WrapIn=self.formatter_fstring_wrap_in
)
self.attrs = attrs

def __str__(self) -> str:
Expand Down
30 changes: 15 additions & 15 deletions ludic/catalog/lists.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
from typing import override

from ludic.attrs import GlobalAttrs
from ludic.attrs import GlobalAttrs, OlAttrs
from ludic.components import Component
from ludic.html import li, ol, ul
from ludic.types import AnyChildren


class ListAttrs(GlobalAttrs, total=False):
items: list[AnyChildren]


class Item(Component[AnyChildren, GlobalAttrs]):
"""Simple component simulating an item in a list.
Expand All @@ -23,41 +19,45 @@ def render(self) -> li:
return li(*self.children, **self.attrs)


class List(Component[Item, ListAttrs]):
class List(Component[Item | str, GlobalAttrs]):
"""Simple component simulating a list.
There is basically just an alias for the :class:`ul` element
without the requirement to pass `li` as children.
Example usage:
List("Item 1", "Item 2")
List(Item("Item 1"), Item("Item 2"))
"""

formatter_fstring_wrap_in = Item

@override
def render(self) -> ul:
if items := self.attrs.get("items"):
children = tuple(map(Item, items))
else:
children = self.children
children = (
child if isinstance(child, Item) else Item(child) for child in self.children
)
return ul(*children, **self.attrs_for(ul))


class NumberedList(Component[Item, ListAttrs]):
class NumberedList(Component[Item | str, OlAttrs]):
"""Simple component simulating a numbered list.
There is basically just an alias for the :class:`ol` element
without the requirement to pass `li` as children.
Example usage:
NumberedList("Item 1", "Item 2")
NumberedList(Item("Item 1"), Item("Item 2"))
"""

formatter_fstring_wrap_in = Item

@override
def render(self) -> ol:
if items := self.attrs.get("items"):
children = tuple(map(Item, items))
else:
children = self.children
children = (
child if isinstance(child, Item) else Item(child) for child in self.children
)
return ol(*children, **self.attrs_for(ol))
18 changes: 9 additions & 9 deletions ludic/elements.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Generic, Unpack, cast
from typing import ClassVar, Generic, Unpack

from .attrs import NoAttrs
from .base import BaseElement
Expand All @@ -16,15 +16,15 @@ class Element(Generic[TChildren, TAttrs], BaseElement):
children: tuple[TChildren, ...]
attrs: TAttrs

formatter_wrap_in: ClassVar[type[BaseElement] | None] = None

def __init__(
self,
*children: TChildren,
# FIXME: https://github.com/python/typing/issues/1399
**attributes: Unpack[TAttrs], # type: ignore
**attrs: Unpack[TAttrs], # type: ignore
) -> None:
super().__init__()
self.attrs = cast(TAttrs, attributes)
self.children = tuple(self.formatter.extract(*children))
super().__init__(*children, **attrs)


class ElementStrict(Generic[*TChildrenArgs, TAttrs], BaseElement):
Expand All @@ -38,15 +38,15 @@ class ElementStrict(Generic[*TChildrenArgs, TAttrs], BaseElement):
children: tuple[*TChildrenArgs]
attrs: TAttrs

formatter_wrap_in: ClassVar[type[BaseElement] | None] = None

def __init__(
self,
*children: *TChildrenArgs,
# FIXME: https://github.com/python/typing/issues/1399
**attrs: Unpack[TAttrs], # type: ignore
) -> None:
super().__init__()
self.attrs = cast(TAttrs, attrs)
self.children = tuple(self.formatter.extract(*children))
super().__init__(*children, **attrs)


class Blank(Element[TChildren, NoAttrs]):
Expand All @@ -57,7 +57,7 @@ class Blank(Element[TChildren, NoAttrs]):
"""

def __init__(self, *children: TChildren) -> None:
super().__init__(*self.formatter.extract(*children))
super().__init__(*children)

def to_html(self) -> str:
return "".join(map(str, self.children))
15 changes: 10 additions & 5 deletions ludic/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def append(self, obj: Any) -> str:

return f"{{{random_id}:id}}"

def extract(self, *args: Any) -> list[Any]:
def extract(self, *args: Any, WrapIn: type | None = None) -> tuple[Any, ...]:
"""Extract identifiers from the given arguments.
Example:
Expand All @@ -205,24 +205,29 @@ def extract(self, *args: Any) -> list[Any]:
["test ", "foo", " ", {"bar": "baz"}]
Args:
WrapIn
args (Any): The arguments to extract identifiers from.
Returns:
Any: The extracted arguments.
"""
extracted_args: list[Any] = []
arguments: list[Any] = []
for arg in args:
if isinstance(arg, str) and (parts := extract_identifiers(arg)):
cache = self.get()
extracted_args.extend(
extracted_args = (
cache.pop(part) if isinstance(part, int) else part
for part in parts
if not isinstance(part, int) or part in cache
)
if WrapIn is not None:
arguments.append(WrapIn(*extracted_args))
else:
arguments.extend(extracted_args)
self._context.set(cache)
else:
extracted_args.append(arg)
return extracted_args
arguments.append(arg)
return tuple(arguments)

def clear(self) -> None:
"""Clear the context memory."""
Expand Down
20 changes: 20 additions & 0 deletions tests/test_catalog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from ludic.catalog.forms import ChoiceField, Form, InputField, TextAreaField
from ludic.catalog.headers import H1, H2, H3, H4, Anchor
from ludic.catalog.items import Key, Pairs, Value
from ludic.catalog.lists import Item, List, NumberedList
from ludic.catalog.messages import (
Message,
MessageDanger,
Expand Down Expand Up @@ -240,3 +241,22 @@ def test_choice_field() -> None:
'</div>'
'</div>'
) # fmt: skip


def test_items() -> None:
assert List("A", "B", "C").to_html() == "<ul><li>A</li><li>B</li><li>C</li></ul>"
assert List(f"Test {b("yes")}", "D").to_html() == (
"<ul>"
"<li>Test <b>yes</b></li>"
"<li>D</li>"
"</ul>"
) # fmt: skip
assert NumberedList(
Item(f"Test {b("ol")}"),
Item("E"),
).to_html() == (
"<ol>"
"<li>Test <b>ol</b></li>"
"<li>E</li>"
"</ol>"
) # fmt: skip
3 changes: 2 additions & 1 deletion tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def test_format_context() -> None:
second = ctx.append({"bar": "baz"})
extracts = ctx.extract(f"test {first} {second}")

assert extracts == ["test ", "foo", " ", {"bar": "baz"}]
assert extracts == ("test ", "foo", " ", {"bar": "baz"})


def test_format_context_in_elements() -> None:
Expand Down Expand Up @@ -63,6 +63,7 @@ def test_component_with_f_string() -> None:
paragraph = Paragraph(
f"Hello, how {strong("are you")}? Click {Link("here", to="https://example.com")}.",
)
assert len(paragraph.children) == 5
assert isinstance(paragraph.children[3], Link)
assert paragraph.children[3].attrs["to"] == "https://example.com"
assert paragraph.to_string(pretty=False) == (
Expand Down

0 comments on commit c2e9741

Please sign in to comment.