diff --git a/README.md b/README.md index e7a3016..bca23a1 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,8 @@ cookiecutter gh:getludic/template ```python from typing import override +from ludic import Attrs, Component from ludic.html import a -from ludic.types import Attrs, Component class LinkAttrs(Attrs): to: str diff --git a/examples/__init__.py b/examples/__init__.py index 6f27bdb..074850b 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -4,9 +4,10 @@ from ludic.attrs import NoAttrs from ludic.catalog.layouts import Center, Stack from ludic.catalog.pages import Body, Head, HtmlPage +from ludic.components import Component from ludic.html import meta from ludic.styles import set_default_theme, themes, types -from ludic.types import AnyChildren, Component +from ludic.types import AnyChildren set_default_theme(themes.LightTheme(measure=types.Size(90, "ch"))) diff --git a/examples/bulk_update.py b/examples/bulk_update.py index a517855..6ad994c 100644 --- a/examples/bulk_update.py +++ b/examples/bulk_update.py @@ -2,14 +2,15 @@ from examples import Page, init_db +from ludic.attrs import Attrs from ludic.catalog.buttons import ButtonPrimary from ludic.catalog.forms import FieldMeta, Form from ludic.catalog.headers import H1, H2 from ludic.catalog.layouts import Cluster from ludic.catalog.quotes import Quote from ludic.catalog.tables import ColumnMeta, Table, create_rows +from ludic.components import Inline from ludic.html import span, style -from ludic.types import Attrs from ludic.web import Endpoint, LudicApp from ludic.web.parsers import ListParser @@ -31,7 +32,7 @@ class PeopleAttrs(Attrs): people: list[PersonAttrs] -class Toast(span): +class Toast(Inline): id: str = "toast" target: str = f"#{id}" styles = style.use( diff --git a/examples/click_to_edit.py b/examples/click_to_edit.py index 62b9ac0..8a582a1 100644 --- a/examples/click_to_edit.py +++ b/examples/click_to_edit.py @@ -2,13 +2,13 @@ from examples import Page, init_db +from ludic.attrs import Attrs from ludic.catalog.buttons import Button, ButtonDanger, ButtonPrimary from ludic.catalog.forms import FieldMeta, Form, create_fields from ludic.catalog.headers import H1, H2 from ludic.catalog.items import Pairs from ludic.catalog.layouts import Box, Cluster, Stack from ludic.catalog.quotes import Quote -from ludic.types import Attrs from ludic.web import Endpoint, LudicApp from ludic.web.exceptions import NotFoundError from ludic.web.parsers import Parser, ValidationError diff --git a/examples/click_to_load.py b/examples/click_to_load.py index 379244d..2598e0f 100644 --- a/examples/click_to_load.py +++ b/examples/click_to_load.py @@ -2,12 +2,15 @@ from examples import Page +from ludic.attrs import Attrs from ludic.catalog.buttons import ButtonPrimary from ludic.catalog.headers import H1, H2 from ludic.catalog.quotes import Quote from ludic.catalog.tables import Table, TableHead, TableRow +from ludic.components import Component, ComponentStrict +from ludic.elements import Blank from ludic.html import td -from ludic.types import Attrs, Blank, Component, ComponentStrict, URLType +from ludic.types import URLType from ludic.web import Endpoint, LudicApp from ludic.web.datastructures import QueryParams diff --git a/examples/infinite_scroll.py b/examples/infinite_scroll.py index 1cd0d5d..f452244 100644 --- a/examples/infinite_scroll.py +++ b/examples/infinite_scroll.py @@ -2,10 +2,12 @@ from examples import Page +from ludic.attrs import Attrs from ludic.catalog.headers import H1, H2 from ludic.catalog.quotes import Quote from ludic.catalog.tables import Table, TableHead, TableRow -from ludic.types import Attrs, Blank, Component +from ludic.components import Component +from ludic.elements import Blank from ludic.web import Endpoint, LudicApp from ludic.web.datastructures import QueryParams diff --git a/ludic/__init__.py b/ludic/__init__.py index e69de29..a110bcc 100644 --- a/ludic/__init__.py +++ b/ludic/__init__.py @@ -0,0 +1,14 @@ +from ludic.attrs import Attrs, GlobalAttrs, NoAttrs +from ludic.components import Block, Component, ComponentStrict, Inline +from ludic.elements import Blank + +__all__ = ( + "Attrs", + "GlobalAttrs", + "NoAttrs", + "Block", + "Component", + "ComponentStrict", + "Inline", + "Blank", +) diff --git a/ludic/attrs.py b/ludic/attrs.py index 297a61c..6f17884 100644 --- a/ludic/attrs.py +++ b/ludic/attrs.py @@ -1,8 +1,6 @@ -from typing import Annotated, Literal, Protocol +from typing import Annotated, Literal, Protocol, TypedDict -from .base import Attrs as Attrs -from .base import NoAttrs as NoAttrs -from .styles import CSSProperties +from .styles.types import CSSProperties class URLType(Protocol): @@ -15,6 +13,31 @@ class Alias(str): """Alias type for attributes.""" +class Attrs(TypedDict, total=False): + """Attributes of an element or component. + + Example usage:: + + class PersonAttrs(Attributes): + name: str + age: NotRequired[int] + + class Person(Component[PersonAttrs]): + @override + def render(self) -> dl: + return dl( + dt("Name"), + dd(self.attrs["name"]), + dt("Age"), + dd(self.attrs.get("age", "N/A")), + ) + """ + + +class NoAttrs(TypedDict): + """Placeholder for element with no attributes.""" + + class HtmlAttrs(Attrs, total=False): """Common attributes for HTML elements.""" diff --git a/ludic/base.py b/ludic/base.py index f212c43..d770f9d 100644 --- a/ludic/base.py +++ b/ludic/base.py @@ -1,94 +1,21 @@ from abc import ABCMeta -from collections.abc import Iterator, Mapping, MutableMapping, Sequence -from typing import ( - Any, - ClassVar, - Generic, - Never, - TypeAlias, - TypedDict, - TypeVar, - TypeVarTuple, - Unpack, - cast, -) +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, ClassVar from .format import FormatContext, format_attrs, format_element -from .styles import GlobalStyles, Theme, get_default_theme -from .utils import get_element_attrs_annotations - -ELEMENT_REGISTRY: MutableMapping[str, list[type["BaseElement"]]] = {} - - -class Safe(str): - """Marker for a string that is safe to use as is without HTML escaping. - - That means the content of the string is not escaped when rendered. - - Usage: - - >>> div(Safe("Hello World!")).to_html() - '
Hello World!
' - """ - - escape = False - - -class JavaScript(Safe): - """Marker for a JavaScript string. - - The content of this string is not escaped when rendered. - - Usage: - - js = JavaScript("alert('Hello World!')") - """ - - -class Attrs(TypedDict, total=False): - """Attributes of an element or component. - - Example usage:: - - class PersonAttrs(Attributes): - name: str - age: NotRequired[int] - - class Person(Component[PersonAttrs]): - @override - def render(self) -> dl: - return dl( - dt("Name"), - dd(self.attrs["name"]), - dt("Age"), - dd(self.attrs.get("age", "N/A")), - ) - """ - - -class NoAttrs(TypedDict): - """Placeholder for element with no attributes.""" class BaseElement(metaclass=ABCMeta): html_header: ClassVar[str | None] = None html_name: ClassVar[str | None] = None - void_element: ClassVar[bool] = False - formatter: ClassVar[FormatContext] = FormatContext("element_formatter") - classes: ClassVar[Sequence[str]] = [] - styles: ClassVar["GlobalStyles"] = {} + formatter: ClassVar[FormatContext] = FormatContext("element_formatter") children: Sequence[Any] attrs: Mapping[str, Any] - context: dict[str, Any] - def __init_subclass__(cls) -> None: - ELEMENT_REGISTRY.setdefault(cls.__name__, []) - ELEMENT_REGISTRY[cls.__name__].append(cls) - def __init__(self, *children: Any, **attrs: Any) -> None: self.context = {} self.children = children @@ -116,21 +43,8 @@ def __eq__(self, other: Any) -> bool: and self.attrs == other.attrs ) - def _format_attributes( - self, classes: list[str] | None = None, is_html: bool = False - ) -> str: - attrs: dict[str, Any] - if is_html: - attrs = format_attrs(type(self), self.attrs, is_html=True) - else: - attrs = self.aliased_attrs - - if classes: - if "class" in attrs: - attrs["class"] += " " + " ".join(classes) - else: - attrs["class"] = " ".join(classes) - + def _format_attributes(self, is_html: bool = False) -> str: + attrs: dict[str, Any] = format_attrs(self.attrs, is_html=is_html) return " ".join( f'{key}="{value}"' if '"' not in value else f"{key}='{value}'" for key, value in attrs.items() @@ -147,21 +61,13 @@ def _format_children(self) -> str: @property def aliased_attrs(self) -> dict[str, Any]: """Attributes as a dict with keys renamed to their aliases.""" - return format_attrs(type(self), self.attrs) + return format_attrs(self.attrs) @property def text(self) -> str: """Get the text content of the element.""" return "".join(getattr(child, "text", str(child)) for child in self.children) - @property - def theme(self) -> Theme: - """Get the theme of the element.""" - if context_theme := self.context.get("theme"): - if isinstance(context_theme, Theme): - return context_theme - return get_default_theme() - def is_simple(self) -> bool: """Check if the element is simple (i.e. contains only one primitive type).""" return len(self) == 1 and isinstance(self.children[0], str | int | float | bool) @@ -206,123 +112,17 @@ def to_string(self, pretty: bool = True, _level: int = 0) -> str: def to_html(self) -> str: """Convert an element tree to an HTML string.""" - dom = self - classes = list(dom.classes) - - while id(dom) != id(rendered_dom := dom.render()): - rendered_dom.context.update(dom.context) - dom = rendered_dom - classes += dom.classes - - element_tag = f"{dom.html_header}\n" if dom.html_header else "" - children_str = dom._format_children() if dom.children else "" + element_tag = f"{self.html_header}\n" if self.html_header else "" + children_str = self._format_children() if self.children else "" - if dom.html_name == "__hidden__": - element_tag += children_str - return element_tag - - element_tag += f"<{dom.html_name}" - if dom.has_attributes() or classes: - attributes_str = dom._format_attributes(classes, is_html=True) + element_tag += f"<{self.html_name}" + if self.has_attributes(): + attributes_str = self._format_attributes(is_html=True) element_tag += f" {attributes_str}" - if dom.children or not dom.void_element: - element_tag += f">{children_str}" + if not self.void_element: + element_tag += f">{children_str}" else: element_tag += ">" return element_tag - - def attrs_for(self, cls: type["BaseElement"]) -> dict[str, Any]: - """Get the attributes of this component that are defined in the given element. - - This is useful so that you can pass common attributes to an element - without having to pass them from a parent one by one. - - Args: - cls (type[BaseElement]): The element to get the attributes of. - - """ - return { - key: value - for key, value in self.attrs.items() - if key in get_element_attrs_annotations(cls) - } - - def render(self) -> "BaseElement": - return self - - -NoChildren: TypeAlias = Never -"""Type alias for elements that are not allowed to have children.""" - -PrimitiveChildren: TypeAlias = str | bool | int | float -"""Type alias for elements that are allowed to have only primitive children. - -Primitive children are ``str``, ``bool``, ``int`` and ``float``. -""" - -ComplexChildren: TypeAlias = BaseElement -"""Type alias for elements that are allowed to have only non-primitive children.""" - -AnyChildren: TypeAlias = PrimitiveChildren | ComplexChildren | Safe -"""Type alias for elements that are allowed to have any children.""" - -TChildren = TypeVar("TChildren", bound=AnyChildren, covariant=True) -"""Type variable for elements representing type of children (the type of *args). - -See also: :class:`ludic.types.Component`. -""" - -TChildrenArgs = TypeVarTuple("TChildrenArgs") -"""Type variable for strict elements representing type of children (the type of *args). - -See also: :class:`ludic.types.ComponentStrict`. -""" - -TAttrs = TypeVar("TAttrs", bound=Attrs | NoAttrs, covariant=True) -"""Type variable for elements representing type of attributes (the type of **kwargs).""" - - -class Element(Generic[TChildren, TAttrs], BaseElement): - """Base class for Ludic elements. - - Args: - *children (TChild): The children of the element. - **attrs (Unpack[TAttrs]): The attributes of the element. - """ - - children: tuple[TChildren, ...] - attrs: TAttrs - - def __init__( - self, - *children: TChildren, - # FIXME: https://github.com/python/typing/issues/1399 - **attributes: Unpack[TAttrs], # type: ignore - ) -> None: - super().__init__() - self.attrs = cast(TAttrs, attributes) - self.children = tuple(self.formatter.extract(*children)) - - -class ElementStrict(Generic[*TChildrenArgs, TAttrs], BaseElement): - """Base class for strict elements (elements with concrete types of children). - - Args: - *children (*TChildTuple): The children of the element. - **attrs (Unpack[TAttrs]): The attributes of the element. - """ - - children: tuple[*TChildrenArgs] - attrs: TAttrs - - 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)) diff --git a/ludic/catalog/buttons.py b/ludic/catalog/buttons.py index aba44e6..1baff59 100644 --- a/ludic/catalog/buttons.py +++ b/ludic/catalog/buttons.py @@ -1,8 +1,9 @@ from typing import override from ludic.attrs import ButtonAttrs +from ludic.components import ComponentStrict from ludic.html import button, style -from ludic.types import ComponentStrict, PrimitiveChildren +from ludic.types import PrimitiveChildren from .typography import Link diff --git a/ludic/catalog/forms.py b/ludic/catalog/forms.py index d1a3b5e..d773134 100644 --- a/ludic/catalog/forms.py +++ b/ludic/catalog/forms.py @@ -12,11 +12,11 @@ SelectAttrs, TextAreaAttrs, ) +from ludic.base import BaseElement +from ludic.components import Component from ludic.html import div, form, input, label, option, select, style, textarea from ludic.types import ( - BaseElement, ComplexChildren, - Component, NoChildren, PrimitiveChildren, TAttrs, diff --git a/ludic/catalog/headers.py b/ludic/catalog/headers.py index cb33d02..f2f21ec 100644 --- a/ludic/catalog/headers.py +++ b/ludic/catalog/headers.py @@ -1,8 +1,8 @@ from typing import override from ludic.attrs import Attrs, GlobalAttrs +from ludic.components import Component, ComponentStrict from ludic.html import a, div, h1, h2, h3, h4, style -from ludic.types import Component, ComponentStrict from .utils import text_to_kebab diff --git a/ludic/catalog/icons.py b/ludic/catalog/icons.py index c22d6a9..1f6fafa 100644 --- a/ludic/catalog/icons.py +++ b/ludic/catalog/icons.py @@ -1,9 +1,9 @@ from typing import override from ludic.attrs import GlobalAttrs, ImgAttrs -from ludic.base import AnyChildren +from ludic.components import Component, ComponentStrict from ludic.html import img, span, style -from ludic.types import Component, ComponentStrict, NoChildren +from ludic.types import AnyChildren, NoChildren class Icon(Component[NoChildren, ImgAttrs]): diff --git a/ludic/catalog/items.py b/ludic/catalog/items.py index 61c768e..0ff737b 100644 --- a/ludic/catalog/items.py +++ b/ludic/catalog/items.py @@ -2,8 +2,9 @@ from typing import override from ludic.attrs import GlobalAttrs +from ludic.components import Component from ludic.html import dd, dl, dt -from ludic.types import Component, PrimitiveChildren +from ludic.types import PrimitiveChildren from .utils import attr_to_camel diff --git a/ludic/catalog/layouts.py b/ludic/catalog/layouts.py index ec328f6..36a4a41 100644 --- a/ludic/catalog/layouts.py +++ b/ludic/catalog/layouts.py @@ -1,8 +1,9 @@ from typing import override from ludic.attrs import GlobalAttrs +from ludic.components import Block, ComponentStrict, Inline from ludic.html import div, style -from ludic.types import AnyChildren, ComponentStrict +from ludic.types import AnyChildren class Stack(div): @@ -53,7 +54,7 @@ class Stack(div): ) -class Box(div): +class Box(Block): """A component which applies padding from all sides. Thus the inner content of the `Box` is spaced in all directions equally. @@ -102,7 +103,7 @@ class Box(div): ) -class Center(div): +class Center(Block): """A component which horizontally centers its children. Example usage: @@ -127,7 +128,7 @@ class Center(div): ) -class Cluster(div): +class Cluster(Inline): """A component for inline children to be rendered in a row. All contained children have a space (margin) between them. @@ -166,7 +167,7 @@ class Cluster(div): ) -class Sidebar(div): +class Sidebar(Block): """The sidebar part of a WithSidebar component.""" classes = ["sidebar"] @@ -221,7 +222,7 @@ def render(self) -> div: return div(self.children[0], self.children[1], **self.attrs) -class Switcher(div): +class Switcher(Block): """A component switching between horizontal and vertical layouts. All the children are either composed horizontally or vertically @@ -260,7 +261,7 @@ class Switcher(div): ) -class Cover(div): +class Cover(Block): """A component which covers the whole viewport. Example usage: @@ -295,7 +296,7 @@ class Cover(div): ) -class Grid(div): +class Grid(Block): """A component which creates a grid layout. Example usage: @@ -328,7 +329,7 @@ class Grid(div): ) -class Frame(div): +class Frame(Block): """A component which creates a frame layout. Example usage: diff --git a/ludic/catalog/lists.py b/ludic/catalog/lists.py index 22b6d48..a92f164 100644 --- a/ludic/catalog/lists.py +++ b/ludic/catalog/lists.py @@ -1,8 +1,9 @@ from typing import override from ludic.attrs import GlobalAttrs +from ludic.components import Component from ludic.html import li, ol, ul -from ludic.types import AnyChildren, Component +from ludic.types import AnyChildren class ListAttrs(GlobalAttrs, total=False): diff --git a/ludic/catalog/loaders.py b/ludic/catalog/loaders.py index 3678d70..8ab5eca 100644 --- a/ludic/catalog/loaders.py +++ b/ludic/catalog/loaders.py @@ -1,8 +1,9 @@ from typing import NotRequired, override from ludic.attrs import GlobalAttrs +from ludic.components import Component from ludic.html import div, style -from ludic.types import AnyChildren, Component, URLType +from ludic.types import AnyChildren, URLType class Loading(Component[AnyChildren, GlobalAttrs]): diff --git a/ludic/catalog/messages.py b/ludic/catalog/messages.py index 5cad109..b1dc302 100644 --- a/ludic/catalog/messages.py +++ b/ludic/catalog/messages.py @@ -1,8 +1,9 @@ from typing import override from ludic.attrs import GlobalAttrs +from ludic.components import Component from ludic.html import div, style -from ludic.types import AnyChildren, Component +from ludic.types import AnyChildren class Title(Component[AnyChildren, GlobalAttrs]): diff --git a/ludic/catalog/navigation.py b/ludic/catalog/navigation.py index 853588c..9148eb4 100644 --- a/ludic/catalog/navigation.py +++ b/ludic/catalog/navigation.py @@ -1,8 +1,9 @@ from typing import NotRequired, override from ludic.attrs import GlobalAttrs +from ludic.components import Component, ComponentStrict from ludic.html import h2, li, nav, style, ul -from ludic.types import Component, ComponentStrict, PrimitiveChildren +from ludic.types import PrimitiveChildren from .buttons import ButtonLink diff --git a/ludic/catalog/pages.py b/ludic/catalog/pages.py index eb3d06f..7bac056 100644 --- a/ludic/catalog/pages.py +++ b/ludic/catalog/pages.py @@ -2,8 +2,10 @@ from typing import override from ludic.attrs import Attrs, NoAttrs +from ludic.base import BaseElement +from ludic.components import Component, ComponentStrict from ludic.html import body, head, html, link, meta, script, style, title -from ludic.types import AnyChildren, BaseElement, Component, ComponentStrict +from ludic.types import AnyChildren class HtmlHeadAttrs(Attrs, total=False): diff --git a/ludic/catalog/quotes.py b/ludic/catalog/quotes.py index dbd47b4..45b4ac2 100644 --- a/ludic/catalog/quotes.py +++ b/ludic/catalog/quotes.py @@ -1,8 +1,9 @@ from typing import override from ludic.attrs import Attrs +from ludic.base import BaseElement +from ludic.components import ComponentStrict from ludic.html import a, blockquote, div, footer, p, style -from ludic.types import BaseElement, ComponentStrict class QuoteAttrs(Attrs, total=False): diff --git a/ludic/catalog/tables.py b/ludic/catalog/tables.py index 7eeb399..7810187 100644 --- a/ludic/catalog/tables.py +++ b/ludic/catalog/tables.py @@ -7,12 +7,11 @@ from typing_extensions import TypeVar from ludic.attrs import GlobalAttrs +from ludic.base import BaseElement +from ludic.components import Component, ComponentStrict from ludic.html import div, style, table, tbody, td, th, thead, tr from ludic.types import ( AnyChildren, - BaseElement, - Component, - ComponentStrict, PrimitiveChildren, TAttrs, ) diff --git a/ludic/catalog/typography.py b/ludic/catalog/typography.py index 6b97144..8f8cb81 100644 --- a/ludic/catalog/typography.py +++ b/ludic/catalog/typography.py @@ -9,13 +9,11 @@ except ImportError: pygments_loaded = False -from ludic.attrs import GlobalAttrs, HyperlinkAttrs +from ludic.attrs import Attrs, GlobalAttrs, HyperlinkAttrs +from ludic.components import Component, ComponentStrict from ludic.html import a, code, p, pre, style from ludic.types import ( AnyChildren, - Attrs, - Component, - ComponentStrict, PrimitiveChildren, Safe, ) diff --git a/ludic/components.py b/ludic/components.py index 009bb62..6bf50f8 100644 --- a/ludic/components.py +++ b/ludic/components.py @@ -1,10 +1,77 @@ from abc import ABCMeta, abstractmethod +from collections.abc import MutableMapping, Sequence +from typing import Any, ClassVar, override + +from .attrs import GlobalAttrs +from .base import BaseElement +from .elements import Element, ElementStrict +from .html import div, span +from .styles import Theme, get_default_theme +from .styles.types import GlobalStyles +from .types import AnyChildren, TAttrs, TChildren, TChildrenArgs +from .utils import get_element_attrs_annotations + +COMPONENT_REGISTRY: MutableMapping[str, list[type["BaseComponent"]]] = {} + + +class BaseComponent(BaseElement, metaclass=ABCMeta): + classes: ClassVar[Sequence[str]] = [] + styles: ClassVar[GlobalStyles] = {} + + @property + def theme(self) -> Theme: + """Get the theme of the element.""" + if context_theme := self.context.get("theme"): + if isinstance(context_theme, Theme): + return context_theme + return get_default_theme() + + def __init_subclass__(cls) -> None: + COMPONENT_REGISTRY.setdefault(cls.__name__, []) + COMPONENT_REGISTRY[cls.__name__].append(cls) + + def _add_classes(self, classes: list[str], element: BaseElement) -> None: + if classes: + if "class_" in element.attrs: + element.attrs["class_"] += " " + " ".join(classes) # type: ignore + else: + element.attrs["class_"] = " ".join(classes) # type: ignore + + def attrs_for(self, cls: type["BaseElement"]) -> dict[str, Any]: + """Get the attributes of this component that are defined in the given element. + + This is useful so that you can pass common attributes to an element + without having to pass them from a parent one by one. + + Args: + cls (type[BaseElement]): The element to get the attributes of. + + """ + return { + key: value + for key, value in self.attrs.items() + if key in get_element_attrs_annotations(cls) + } + + def to_html(self) -> str: + dom: BaseElement | BaseComponent = self + classes: list[str] = [] + + while isinstance(dom, BaseComponent): + classes += dom.classes + context = dom.context + dom = dom.render() + dom.context.update(context) + + self._add_classes(classes, dom) + return dom.to_html() -from .base import BaseElement, Element, ElementStrict -from .types import NoAttrs, TAttrs, TChildren, TChildrenArgs + @abstractmethod + def render(self) -> BaseElement: + """Render the component as an instance of :class:`BaseElement`.""" -class Component(Element[TChildren, TAttrs], metaclass=ABCMeta): +class Component(Element[TChildren, TAttrs], BaseComponent): """Base class for components. A component subclasses an :class:`Element` and represents any element @@ -31,12 +98,8 @@ def render(self) -> dl: """ - @abstractmethod - def render(self) -> BaseElement: - """Render the component as an instance of :class:`BaseElement`.""" - -class ComponentStrict(ElementStrict[*TChildrenArgs, TAttrs], metaclass=ABCMeta): +class ComponentStrict(ElementStrict[*TChildrenArgs, TAttrs], BaseComponent): """Base class for strict components. A component subclasses an :class:`ElementStrict` and represents any @@ -69,19 +132,18 @@ def render(self) -> dl: We also specify age as an optional key-word argument. """ - @abstractmethod - def render(self) -> BaseElement: - """Render the component as an instance of :class:`BaseElement`.""" +class Block(Component[AnyChildren, GlobalAttrs]): + """Component rendering as a div.""" -class Blank(Element[TChildren, NoAttrs]): - """Element representing no element at all, just children. + @override + def render(self) -> div: + return div(*self.children, **self.attrs) - The purpose of this element is to be able to return only children - when rendering a component. - """ - html_name = "__hidden__" +class Inline(Component[AnyChildren, GlobalAttrs]): + """Component rendering as a span.""" - def __init__(self, *children: TChildren) -> None: - super().__init__(*self.formatter.extract(*children)) + @override + def render(self) -> span: + return span(*self.children, **self.attrs) diff --git a/ludic/elements.py b/ludic/elements.py new file mode 100644 index 0000000..e48d05f --- /dev/null +++ b/ludic/elements.py @@ -0,0 +1,63 @@ +from typing import Generic, Unpack, cast + +from .attrs import NoAttrs +from .base import BaseElement +from .types import TAttrs, TChildren, TChildrenArgs + + +class Element(Generic[TChildren, TAttrs], BaseElement): + """Base class for Ludic elements. + + Args: + *children (TChild): The children of the element. + **attrs (Unpack[TAttrs]): The attributes of the element. + """ + + children: tuple[TChildren, ...] + attrs: TAttrs + + def __init__( + self, + *children: TChildren, + # FIXME: https://github.com/python/typing/issues/1399 + **attributes: Unpack[TAttrs], # type: ignore + ) -> None: + super().__init__() + self.attrs = cast(TAttrs, attributes) + self.children = tuple(self.formatter.extract(*children)) + + +class ElementStrict(Generic[*TChildrenArgs, TAttrs], BaseElement): + """Base class for strict elements (elements with concrete types of children). + + Args: + *children (*TChildTuple): The children of the element. + **attrs (Unpack[TAttrs]): The attributes of the element. + """ + + children: tuple[*TChildrenArgs] + attrs: TAttrs + + 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)) + + +class Blank(Element[TChildren, NoAttrs]): + """Element representing no element at all, just children. + + The purpose of this element is to be able to return only children + when rendering a component. + """ + + def __init__(self, *children: TChildren) -> None: + super().__init__(*self.formatter.extract(*children)) + + def to_html(self) -> str: + return "".join(map(str, self.children)) diff --git a/ludic/format.py b/ludic/format.py index c7faed4..c9ffa16 100644 --- a/ludic/format.py +++ b/ludic/format.py @@ -3,14 +3,15 @@ import re from collections.abc import Mapping from contextvars import ContextVar -from typing import Annotated, Any, Final, TypeVar, get_args, get_origin - -from .utils import get_element_attrs_annotations - -EXTRACT_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"\{(\d+:id)\}") +from typing import Any, Final, TypeVar T = TypeVar("T") +_EXTRACT_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"\{(\d+:id)\}") +_ALIASES: Final[dict[str, str]] = { + "classes": "class", +} + def format_attr_value(key: str, value: Any, is_html: bool = False) -> str: """Format an HTML attribute with the given key and value. @@ -50,49 +51,30 @@ def _html_escape(value: Any) -> str: return formatted_value -def format_attrs( - attrs_type: Any, attrs: Mapping[str, Any], is_html: bool = False -) -> dict[str, Any]: - """Format the given attributes according to the element's attributes. - - Here is an example of TypedDict definition: - - class PersonAttrs(TypedDict): - name: str - class_: Annotated[str, "class"] - is_adult: bool - - And here is the attrs that will be formatted: +def format_attrs(attrs: Mapping[str, Any], is_html: bool = False) -> dict[str, Any]: + """Format the given attributes. attrs = {"name": "John", "class_": "person", "is_adult": True} The result will be: - >>> format_attrs(PersonAttrs, attrs) + >>> format_attrs(attrs) >>> {"name": "John", "class": "person"} Args: - attrs_type (Any): The element. attrs (dict[str, Any]): The attributes to format. Returns: dict[str, Any]: The formatted attributes. """ - hints = get_element_attrs_annotations(attrs_type, include_extras=True) - - def _get_key(key: str) -> str: - if key in hints and get_origin(hints[key]) is Annotated: - args = get_args(hints[key]) - if len(args) > 1 and isinstance(args[1], str): - return args[1] - elif key.startswith("data_"): - return f"data-{key[5:]}" - return key - result: dict[str, str] = {} for key, value in attrs.items(): if formatted_value := format_attr_value(key, value, is_html=is_html): - alias = _get_key(key) + if key in _ALIASES: + alias = _ALIASES[key] + else: + alias = key.strip("_").replace("_", "-") + if alias in result: result[alias] += " " + formatted_value else: @@ -129,7 +111,7 @@ def extract_identifiers(text: str) -> list[str | int]: Returns: Iterable[int]: The extracted numbers. """ - parts = [_extract_match(match) for match in EXTRACT_NUMBER_RE.split(text) if match] + parts = [_extract_match(match) for match in _EXTRACT_NUMBER_RE.split(text) if match] if any(isinstance(part, int) for part in parts): return parts else: diff --git a/ludic/html.py b/ludic/html.py index 4fd35a5..0cd8f95 100644 --- a/ludic/html.py +++ b/ludic/html.py @@ -1,5 +1,5 @@ from collections.abc import Callable, Iterator -from typing import Self, Unpack, override +from typing import Self, Unpack from .attrs import ( AreaAttrs, @@ -55,6 +55,8 @@ TrackAttrs, VideoAttrs, ) +from .base import BaseElement +from .elements import Element, ElementStrict from .styles import ( Theme, format_styles, @@ -63,11 +65,8 @@ ) from .types import ( AnyChildren, - BaseElement, ComplexChildren, CSSProperties, - Element, - ElementStrict, GlobalStyles, NoChildren, PrimitiveChildren, @@ -579,7 +578,7 @@ def styles(self) -> GlobalStyles: if isinstance(self.children[0], str): return {} elif callable(self.children[0]): - return self.children[0](self.theme) + return self.children[0](self.context["theme"]) else: return self.children[0] @@ -588,29 +587,21 @@ def styles(self, value: GlobalStyles) -> None: self.children = (value,) def to_html(self) -> str: - dom: BaseElement = self - while dom != (rendered_dom := dom.render()): - dom = rendered_dom - attributes = "" - if formatted_attrs := dom._format_attributes(): + if formatted_attrs := self._format_attributes(): attributes = f" {formatted_attrs}" - if isinstance(dom.children[0], str): + if isinstance(self.children[0], str): css_styles = self.children[0] else: css_styles = format_styles(self.styles) return ( - f"<{dom.html_name}{attributes}>\n" + f"<{self.html_name}{attributes}>\n" f"{css_styles}\n" - f"" + f"" ) # fmt: off - @override - def render(self) -> BaseElement: - return self - class script(Element[PrimitiveChildren, ScriptAttrs]): html_name = "script" diff --git a/ludic/styles/collect.py b/ludic/styles/collect.py index beb9895..4fef8d1 100644 --- a/ludic/styles/collect.py +++ b/ludic/styles/collect.py @@ -1,12 +1,11 @@ from collections.abc import Mapping, MutableMapping -from typing import TYPE_CHECKING, Any +from typing import Any + +from ludic.base import BaseElement from .themes import Theme, get_default_theme from .types import CSSProperties, GlobalStyles -if TYPE_CHECKING: - from ludic.types import BaseElement - GLOBAL_STYLES_CACHE: MutableMapping[str, GlobalStyles] = {} @@ -95,7 +94,7 @@ def from_loaded(cache: bool = False, theme: Theme | None = None) -> GlobalStyles Returns: GlobalStyles: Collected styles from loaded components. """ - from ludic.base import ELEMENT_REGISTRY + from ludic.components import COMPONENT_REGISTRY global GLOBAL_STYLES_CACHE theme = theme or get_default_theme() @@ -103,7 +102,9 @@ def from_loaded(cache: bool = False, theme: Theme | None = None) -> GlobalStyles if cache and GLOBAL_STYLES_CACHE.get(theme.name): return GLOBAL_STYLES_CACHE[theme.name] - loaded = (element for elements in ELEMENT_REGISTRY.values() for element in elements) + loaded = ( + element for elements in COMPONENT_REGISTRY.values() for element in elements + ) result = from_components(*loaded, theme=theme) if cache: GLOBAL_STYLES_CACHE[theme.name] = result diff --git a/ludic/styles/themes.py b/ludic/styles/themes.py index 75e5d41..2e40c2b 100644 --- a/ludic/styles/themes.py +++ b/ludic/styles/themes.py @@ -1,11 +1,10 @@ from abc import ABCMeta, abstractmethod from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeVar +from typing import TypeVar -from .types import BaseSize, Color, ColorRange, Size, SizeClamp +from ludic.base import BaseElement -if TYPE_CHECKING: - from ludic.types import BaseElement +from .types import BaseSize, Color, ColorRange, Size, SizeClamp _T = TypeVar("_T", bound="BaseElement") diff --git a/ludic/types.py b/ludic/types.py index 0ad98d5..6c5d0b0 100644 --- a/ludic/types.py +++ b/ludic/types.py @@ -1,25 +1,35 @@ from collections.abc import Iterable, Mapping -from typing import TypedDict - -from .attrs import URLType as URLType -from .base import ( - AnyChildren, - Attrs, - BaseElement, - ComplexChildren, - Element, - ElementStrict, - JavaScript, - NoAttrs, - NoChildren, - PrimitiveChildren, - Safe, - TAttrs, - TChildren, - TChildrenArgs, -) -from .components import Blank, Component, ComponentStrict -from .styles import CSSProperties, GlobalStyles, Theme +from typing import Never, TypeAlias, TypedDict, TypeVar, TypeVarTuple + +from .attrs import Attrs, NoAttrs, URLType +from .base import BaseElement +from .styles import CSSProperties, GlobalStyles + + +class Safe(str): + """Marker for a string that is safe to use as is without HTML escaping. + + That means the content of the string is not escaped when rendered. + + Usage: + + >>> div(Safe("Hello World!")).to_html() + '
Hello World!
' + """ + + escape = False + + +class JavaScript(Safe): + """Marker for a JavaScript string. + + The content of this string is not escaped when rendered. + + Usage: + + js = JavaScript("alert('Hello World!')") + """ + JSONType = ( Mapping[str, "JSONType"] | Iterable["JSONType"] | str | int | float | bool | None @@ -43,27 +53,50 @@ total=False, ) +NoChildren: TypeAlias = Never +"""Type alias for elements that are not allowed to have children.""" + +PrimitiveChildren: TypeAlias = str | bool | int | float +"""Type alias for elements that are allowed to have only primitive children. + +Primitive children are ``str``, ``bool``, ``int`` and ``float``. +""" + +ComplexChildren: TypeAlias = BaseElement +"""Type alias for elements that are allowed to have only non-primitive children.""" + +AnyChildren: TypeAlias = PrimitiveChildren | ComplexChildren | Safe +"""Type alias for elements that are allowed to have any children.""" + +TChildren = TypeVar("TChildren", bound=AnyChildren, covariant=True) +"""Type variable for elements representing type of children (the type of *args). + +See also: :class:`ludic.types.Component`. +""" + +TChildrenArgs = TypeVarTuple("TChildrenArgs") +"""Type variable for strict elements representing type of children (the type of *args). + +See also: :class:`ludic.types.ComponentStrict`. +""" + +TAttrs = TypeVar("TAttrs", bound=Attrs | NoAttrs, covariant=True) +"""Type variable for elements representing type of attributes (the type of **kwargs).""" + + __all__ = ( "AnyChildren", - "Attrs", - "BaseElement", "ComplexChildren", - "Component", - "ComponentStrict", "CSSProperties", - "Element", - "ElementStrict", "GlobalStyles", "Headers", "HXHeaders", + "URLType", "JavaScript", - "NoAttrs", "NoChildren", "PrimitiveChildren", "Safe", "TAttrs", "TChildren", "TChildrenArgs", - "Theme", - "Blank", ) diff --git a/ludic/web/app.py b/ludic/web/app.py index 184f950..8103550 100644 --- a/ludic/web/app.py +++ b/ludic/web/app.py @@ -13,7 +13,7 @@ from starlette.websockets import WebSocket from ludic.attrs import Attrs -from ludic.types import BaseElement +from ludic.base import BaseElement from .datastructures import URLPath from .endpoints import Endpoint diff --git a/ludic/web/endpoints.py b/ludic/web/endpoints.py index 8caee31..7e8b3a6 100644 --- a/ludic/web/endpoints.py +++ b/ludic/web/endpoints.py @@ -7,7 +7,8 @@ from starlette.routing import Route from ludic.catalog.loaders import LazyLoader -from ludic.types import AnyChildren, Component, NoChildren, TAttrs +from ludic.components import Component +from ludic.types import AnyChildren, NoChildren, TAttrs from ludic.utils import get_element_generic_args from .requests import Request diff --git a/ludic/web/responses.py b/ludic/web/responses.py index 4ae355f..0ef0f70 100644 --- a/ludic/web/responses.py +++ b/ludic/web/responses.py @@ -17,7 +17,7 @@ ) from starlette.websockets import WebSocket -from ludic.types import BaseElement +from ludic.base import BaseElement from ludic.web import datastructures as ds from ludic.web.parsers import BaseParser diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index 6c055b7..30bcdaa 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -1,5 +1,7 @@ +from ludic.attrs import Attrs +from ludic.components import Component from ludic.html import style -from ludic.types import AnyChildren, Attrs, Component +from ludic.types import AnyChildren from . import FooTheme diff --git a/tests/styles/test_themes.py b/tests/styles/test_themes.py index 5823283..8c879ab 100644 --- a/tests/styles/test_themes.py +++ b/tests/styles/test_themes.py @@ -1,6 +1,7 @@ from typing import override from ludic.attrs import GlobalAttrs +from ludic.components import Component from ludic.html import a, b, div, style from ludic.styles.themes import ( Colors, @@ -10,7 +11,6 @@ set_default_theme, ) from ludic.styles.types import Color, Size, SizeClamp -from ludic.types import Component from . import BarTheme, FooTheme diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 8c4ef62..9c28977 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,7 +1,7 @@ +from ludic.base import BaseElement from ludic.catalog.typography import Link, Paragraph from ludic.format import FormatContext, format_attr_value from ludic.html import b, div, i, p, strong -from ludic.types import BaseElement def test_format_attr_value() -> None: