tagz is a html tags builder
import io
from copy import copy
from dataclasses import dataclass
from functools import lru_cache
from html import escape
from itertools import chain
from textwrap import indent
from types import MappingProxyType
from typing import (
    Any, Dict, Iterable, Iterator, List, Mapping, MutableMapping, MutableSet,
    Optional, Tuple, Type, Union,
)


class Style(Dict[str, Any]):
    def __init__(self, *args: Any, **kwargs: Any):
        kwargs = {key.replace("_", "-"): value for key, value in kwargs.items()}
        super().__init__(*args, **kwargs)

    def __str__(self) -> str:
        return " ".join(f"{key}: {value};" for key, value in sorted(self.items()))


class StyleSheet(Dict[Union[str, Tuple[str, ...]], Style]):
    def __str__(self) -> str:
        styles = []
        for key, value in self.items():
            if isinstance(key, tuple):
                key = ", ".join(key)
            styles.append(f"{key} {{{value}}}")
        return "\n".join(styles)


@dataclass(frozen=False)
class Tag:
    name: str
    classes: MutableSet[str]
    children: List[Union["Tag", str]]
    attributes: MutableMapping[str, Union[str, None, Style]]

    def __init__(
        self,
        _tag_name: str,
        *_children: Union[str, "Tag"],
        classes: Iterable[str] = (),
        **attributes: Union[str, None, Style]
    ):
        attrs: MutableMapping[str, Union[str, None, Style]] = {}
        for key, value in attributes.items():
            attrs[key.replace("_", "-")] = escape(str(value))

        self.name = escape(_tag_name)
        self.classes = set(classes)
        self.attributes = attrs
        self.children = list(_children)

    def append(self, other: Union["Tag", str]) -> None:
        return self.children.append(other)

    def __setitem__(self, key: str, value: Optional[str]) -> None:
        self.attributes[escape(key)] = escape(value) if value is not None else None

    def __getitem__(self, item: str) -> Union[str, None, Style]:
        return self.attributes[escape(item)]

    def _format_attributes(self) -> str:
        parts = []
        for key, value in self.attributes.items():
            key = escape(key)
            if value is None:
                parts.append(key)
                continue
            value = escape(str(value), quote=True)
            parts.append(f"{key}=\"{value}\"")
        return " ".join(parts)

    def _format_classes(self) -> str:
        if not self.classes:
            return ""
        return f"class=\"{' '.join(map(escape, sorted(self.classes)))}\""

    def __make_parts(self) -> Iterator[str]:
        yield self.name
        classes = self._format_classes()

        if classes:
            yield classes

        attributes = self._format_attributes()
        if attributes:
            yield attributes

    def __repr__(self) -> str:
        if self.children:
            return f"<{' '.join(self.__make_parts())}>...</{self.name}>"
        else:
            return f"<{' '.join(self.__make_parts())}/>"

    def __str__(self) -> str:
        return self._to_string()

    def _to_string(self) -> str:
        with io.StringIO() as fp:
            fp.write(f"<{' '.join(self.__make_parts())}")
            if self.children:
                fp.write(f">")
                for child in self.children:
                    fp.write(str(child))
                fp.write(f"</{self.name}>")
            else:
                fp.write(f"/>")
            return fp.getvalue()

    def _to_pretty_string(self, _indent: str = "") -> str:
        with io.StringIO() as fp:
            fp.write(f"<{' '.join(self.__make_parts())}")
            if self.children:
                fp.write(f">\n")
                for child in self.children:
                    if isinstance(child, Tag):
                        fp.write(child._to_pretty_string("\t"))
                    else:
                        child_str = str(child)
                        if not child_str.strip():
                            continue
                        fp.write(indent(child_str, _indent if _indent else "\t"))
                        fp.write(f"\n")
                fp.write(f"</{self.name}>\n")
            else:
                fp.write(f"/>\n")
            return indent(fp.getvalue(), _indent)

    def to_string(self, pretty: bool = False) -> str:
        return self._to_pretty_string() if pretty else self._to_string()


class TagInstance(Tag):
    __tag_name__: str
    __default_children__: Iterable[Union[str, Tag]] = ()
    __default_attributes__: Optional[Mapping[str, str]] = None

    def __init__(
        self,
        *_children: Union[str, "Tag"],
        classes: Iterable[str] = (),
        **attributes: Union[str, None, Style]
    ):
        attrs: Dict[str, Union[str, None, Style]] = (
            dict(self.__default_attributes__ or {})
        )
        attrs.update(**attributes)
        _children = tuple(
            copy(item) for item in chain(self.__default_children__, _children)
        )

        super().__init__(
            self.__tag_name__,
            *_children,
            classes=classes,
            **attrs
        )

    def __copy__(self) -> "TagInstance":
        children = tuple(copy(item) for item in self.children)
        return self.__class__(
            *children,
            classes=copy(self.classes),
            **self.attributes
        )


@lru_cache(None)
def create_tag_class(tag_name: str, **defaults: Any) -> Type[TagInstance]:
    class_attrs = {"__tag_name__": tag_name}
    if defaults:
        class_attrs.update(defaults)
    return type(
        f"Tag{tag_name.title().replace('-', '')}",
        (TagInstance,), class_attrs,
    )


class HTML:
    def __init__(self, defaults: Mapping[str, Mapping[str, Any]]):
        self.__defaults: Mapping[str, Mapping[str, Any]] = MappingProxyType(defaults)

    def __getitem__(self, tag_name: str) -> Type[TagInstance]:
        tag_name = tag_name.lower().replace("_", "-")
        return create_tag_class(tag_name, **self.__defaults.get(tag_name, {}))

    def __getattr__(self, tag_name: str) -> Type[TagInstance]:
        return self[tag_name.replace("_", "-")]


html = HTML({
    "script": {"__default_children__": ("",)},
})


class Page:
    PREAMBLE: str = "<!doctype html>\n"

    def __init__(
        self, body_element: Optional[Tag] = None,
        head_elements: Iterable[Tag] = (),
        *args: Union[str, "Tag"], **kwargs: str
    ):
        self.body = body_element or html.body()
        self.head = html.head(*head_elements)
        self.html = html.html(self.head, self.body, *args, **kwargs)

    def to_html5(self, pretty: bool = False) -> str:
        with io.StringIO() as fp:
            fp.write(self.PREAMBLE)
            fp.write(self.html.to_string(pretty=pretty))
            return fp.getvalue()


__all__ = (
    "HTML",
    "Page",
    "Style",
    "StyleSheet",
    "Tag",
    "TagInstance",
    "html",
)

tagz.py
mosquito/tagz
from copy import copy

import pytest

from tagz import HTML, Page, Style, Tag, html, StyleSheet


@pytest.fixture
def sample_html_page():
    # Create a sample HTML page for testing
    return Page(
        lang="en",
        body_element=html.body(
            html.h1("Hello, World!"),
            html.p("This is a test."),
        ),
        head_elements=[
            html.title("Sample Page"),
        ],
    )


def test_tag_creation():
    tag = Tag("div", classes=["container"], id="main")
    assert "container" in tag.classes
    assert tag.name == "div"
    assert tag["id"] == "main"

    tag = html.div(classes=["container"], id="main")
    assert "container" in tag.classes
    assert tag.name == "div"
    assert tag["id"] == "main"


def test_tag_string_representation():
    tag = html.p("Hello, World!", html.a("go to index", "", href="/"), html.i())
    assert str(tag) == '<p>Hello, World!<a href="/">go to index</a><i/></p>'
    assert tag.to_string(pretty=True) == (
        '<p>\n\tHello, World!\n\t<a href="/">\n\t\tgo to index\n\t</a>\n\t<i/>\n</p>\n'
    )
    assert html.my_custom_tag is html.my_custom_tag
    assert str(html.my_custom_tag()) == "<my-custom-tag/>"
    assert str(html.my_custom_tag("test")) == "<my-custom-tag>test</my-custom-tag>"


def test_html_generation(sample_html_page):
    html_str = sample_html_page.to_html5()
    assert "<!doctype html>" in html_str
    assert "<html lang=\"en\">" in html_str
    assert "<head>" in html_str
    assert "<title>Sample Page</title>" in html_str
    assert "<body>" in html_str
    assert "<h1>Hello, World!</h1>" in html_str
    assert "<p>This is a test.</p>" in html_str


def test_html_class_factory():
    html_class = HTML({"custom-tag": {"__default_children__": ("Hello",)}})
    custom_tag = html_class.custom_tag()
    assert isinstance(custom_tag, Tag)
    assert str(custom_tag) == "<custom-tag>Hello</custom-tag>"


def test_tag_features():
    div = html.div()

    assert str(div) == "<div/>"
    div.append(html.strong("hello"))

    assert str(div) == "<div><strong>hello</strong></div>"
    div["id"] = "foo"

    assert div["id"] == "foo"
    div["custom_attr"] = " custom value "

    div = html.div()
    div["custom"] = None
    assert str(div) == "<div custom/>"

    div = html.div(classes=["foo", "bar"])
    assert str(div) == '<div class="bar foo"/>'

    div = html.div()
    assert repr(div) == "<div/>"

    div = html.div("Hello")
    assert repr(div) == "<div>...</div>"


def test_style():
    assert str(Style(text_align="center", padding=0)) == 'padding: 0; text-align: center;'

    style = Style()
    assert str(style) == ''

    style['padding'] = 0
    assert str(style) == "padding: 0;"

    style['margin'] = 0
    assert str(style) == 'margin: 0; padding: 0;'

    assert str(
        html.div("red", style=Style(color="#ff0000"))
    ) == (
        '<div style="color: #ff0000;">red</div>'
    )


def test_stylesheet():
    style_sheet = StyleSheet()
    style_sheet['body'] = Style(background_color="#000000", color="#ffffff")
    style_sheet[('div', 'a', 'table')] = Style(background_color="#111111", color="#cccccc")

    assert str(style_sheet) == (
        'body {background-color: #000000; color: #ffffff;}\n'
        'div, a, table {background-color: #111111; color: #cccccc;}'
    )


def test_tag_copy():
    tag = html.div(name='foo')
    clone = copy(tag)
    clone['name'] = 'bar'

    assert tag != clone
    assert tag['name'] != clone['name']
    assert tag['name'] == 'foo'
    assert clone['name'] == 'bar'


tests/test_html.py
mosquito/tagz