diff --git a/README.md b/README.md index 36c3709..c0730be 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # simple_html -### Template-less. Type-safe. Minified by default. +### Template-less. Type-safe. Minified by default. Fast. -simple_html is built to simplify HTML rendering in Python. No templates needed. Just create HTML in -normal Python. In most cases, the code will be more concise than standard HTML. Other benefits include: -- typically renders fewer bytes than template-based rendering +simple_html allows you to create HTML in standard Python. Benefits include: +- it's typically faster than jinja2 -- up to 20x faster +- it typically renders fewer bytes than template-based rendering - types mean your editor and tools can help you write correct code faster -- no framework needed -- lightweight +- lightweight and framework agnostic ### Installation @@ -17,82 +16,97 @@ normal Python. In most cases, the code will be more concise than standard HTML. ### Usage ```python -from simple_html.nodes import body, head, html, p -from simple_html.render import render - -node = html( - head, - body( - p.attrs(id="hello")( - "Hello World!" - ) - ) -) +from simple_html import div, h1, render + +node = div({}, + h1({"id": "hello"}, + "Hello World!" + ) + ) + +render(node) +#

Hello World!

+``` + +There are several ways to render nodes: +```python +from simple_html import br, div, h1, img, render, span + +# raw node +render(br) +#
+# node with attributes only +render(img({"src": "/some/image/url.jpg", "alt": "a great picture"})) +# a great picture + +# node with children render( - node) # returns:

Hello World!

+ div({}, + h1({}, + "something")) +) +#

something

' ``` +Node attributes with `None` as the value will only render the key +```python +from simple_html import div, render + +render(div({"empty-str-attribute": "", "key-only-attr": None})) +#
+``` Strings are escaped by default, but you can pass in `SafeString`s to avoid escaping. ```python -from simple_html.nodes import br, p, safe_string -from simple_html.render import render +from simple_html import br, p, safe_string, render -node = p( - "Escaped & stuff", - br, - safe_string("Not escaped & stuff") -) +node = p({}, + "Escaped & stuff", + br, + safe_string("Not escaped & stuff") + ) render(node) # returns:

Escaped & stuff
Not escaped & stuff

``` -For convenience, many tags are provided, but you can create your own as well: - +Lists and generators are both valid collections of nodes: ```python -from simple_html.nodes import Tag -from simple_html.render import render +from typing import Generator +from simple_html import div, render, Node, br -custom_elem = Tag("custom-elem") -render( - custom_elem.attrs(id="some-custom-elem-id")( - "Wow" - ) -) # returns: Wow -``` +def list_of_nodes_function() -> list[Node]: + return ["neat", br] -Likewise, some attributes have been created as type-safe presets. Note that there are multiple ways to create attributes. -The examples below are all equivalent: -```python -from simple_html.attributes import height, id_ -from simple_html.nodes import div +render(div({}, list_of_nodes_function())) +#
neat
-# **kwargs: recommended for most cases -div.attrs(id="some-id", height="100") +def node_generator() -> Generator[Node, None, None]: + yield "neat" + yield br -# *args: useful for attributes that may be reserved keywords or when type constraints are desired. -# Presets, raw tuples, and kwargs can be used interchangeably. -div.attrs(id_("some-id"), - height(100), - ("class", "abc"), - width="100") -# renders to:
+render(div({}, node_generator())) +#
neat
``` -You can build your own presets, using `str_attr`, `int_attr`, or `bool_attr`. For instance, here are -several of the attribute preset definitions + +For convenience, many tags are provided, but you can create your own as well: ```python -from simple_html.attributes import bool_attr, int_attr, str_attr +from simple_html import Tag, render + +custom_elem = Tag("custom-elem") -checked = bool_attr('checked') -class_ = str_attr('class') -cols = int_attr('cols') +render( + custom_elem({"id": "some-custom-elem-id"}, + "Wow" + ) +) # returns: Wow ``` -But anything that renders to the type of `Attribute` will work. \ No newline at end of file + +Likewise, some attributes have been created as type-safe presets. Note that there are multiple ways to create attributes. \ No newline at end of file diff --git a/bench/simple.py b/bench/simple.py index 3cb6710..68904ff 100644 --- a/bench/simple.py +++ b/bench/simple.py @@ -1,6 +1,6 @@ from typing import List, Tuple -from simple_html.nodes import ( +from simple_html import ( h1, html, title, @@ -12,9 +12,10 @@ li, safe_string, br, - meta, DOCTYPE_HTML5, + meta, + DOCTYPE_HTML5, + render ) -from simple_html.render import render def hello_world_empty(objs: List[None]) -> None: diff --git a/poetry.lock b/poetry.lock index c620312..2d5e592 100644 --- a/poetry.lock +++ b/poetry.lock @@ -508,4 +508,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "4159ff97884acb4fb7805f2f27ecf57124dc82ef56b0cc4cff04f53b542fa746" +content-hash = "f5d4f84d632e8050ac4920f79b0186a6e61ab162c4f229a5b1a0b3378aceaa09" diff --git a/pyproject.toml b/pyproject.toml index 4d22d99..bb54ebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "simple-html" -version = "0.7.0" +version = "1.0.0" readme = "README.md" description = "Template-less html rendering in Python" authors = ["Keith Philpott "] @@ -19,9 +19,9 @@ mypy = "1.6.0" pytest = "7.4.2" setuptools = "68.2.2" black = "23.9.1" -django = "^4.2.6" -fast-html = "^1.0.3" -dominate = "^2.8.0" +django = "4.2.6" +fast-html = "1.0.3" +dominate = "2.8.0" [build-system] diff --git a/simple_html/__init__.py b/simple_html/__init__.py index e69de29..53d21bf 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -0,0 +1,204 @@ +from html import escape +from types import GeneratorType +from typing import Tuple, Union, Dict, List, Generator, Optional, TYPE_CHECKING, cast + +SafeString = Tuple[str] + + +def safe_string(x: str) -> SafeString: + return (x,) + + +Node = Union[ + str, + SafeString, + "Tag", + "TagTuple", + List["Node"], + Generator["Node", None, None], +] + +TagTuple = Tuple[str, Tuple[Node, ...], str] + + +class Tag: + __slots__ = ('tag_start', 'rendered', "closing_tag", "no_children_close") + + def __init__(self, name: str, self_closing: bool = False) -> None: + self.tag_start = f"<{name}" + if self_closing: + self.closing_tag = "" + self.no_children_close = "/>" + else: + self.closing_tag = f"" + self.no_children_close = f">{self.closing_tag}" + self.rendered = f"{self.tag_start}{self.no_children_close}" + + def __call__( + self, attributes: Dict[str, Optional[str]], *children: Node + ) -> Union[TagTuple, SafeString]: + if attributes: + # in this case this is faster than attrs = "".join([...]) + attrs = "" + for key, val in attributes.items(): + attrs += (" " + key if val is None else f' {key}="{val}"') + + if children: + return f"{self.tag_start}{attrs}>", children, self.closing_tag + else: + return f"{self.tag_start}{attrs}{self.no_children_close}", + return f"{self.tag_start}>", children, self.closing_tag + + +DOCTYPE_HTML5 = safe_string("") + +a = Tag("a") +abbr = Tag("abbr") +address = Tag("address") +area = Tag("area", True) +article = Tag("article") +aside = Tag("aside") +audio = Tag("audio") +b = Tag("b") +base = Tag("base", True) +bdi = Tag("bdi") +bdo = Tag("bdo") +blockquote = Tag("blockquote") +body = Tag("body") +br = Tag("br", True) +button = Tag("button") +canvas = Tag("canvas") +center = Tag("center") +caption = Tag("caption") +cite = Tag("cite") +code = Tag("code") +col = Tag("col") +colgroup = Tag("colgroup") +datalist = Tag("datalist") +dd = Tag("dd") +details = Tag("details") +del_ = Tag("del") +dfn = Tag("dfn") +div = Tag("div") +dl = Tag("dl") +dt = Tag("dt") +em = Tag("em") +embed = Tag("embed", True) +fieldset = Tag("fieldset") +figure = Tag("figure") +figcaption = Tag("figcaption") +footer = Tag("footer") +font = Tag("font") +form = Tag("form") +head = Tag("head") +header = Tag("header") +h1 = Tag("h1") +h2 = Tag("h2") +h3 = Tag("h3") +h4 = Tag("h4") +h5 = Tag("h5") +h6 = Tag("h6") +hr = Tag("hr", True) +html = Tag("html") +i = Tag("i") +iframe = Tag("iframe", True) +img = Tag("img", True) +input_ = Tag("input", True) +ins = Tag("ins") +kbd = Tag("kbd") +label = Tag("label") +legend = Tag("legend") +li = Tag("li") +link = Tag("link", True) +main = Tag("main") +mark = Tag("mark") +marquee = Tag("marquee") +math = Tag("math") +menu = Tag("menu") +menuitem = Tag("menuitem") +meta = Tag("meta", True) +meter = Tag("meter") +nav = Tag("nav") +object_ = Tag("object") +noscript = Tag("noscript") +ol = Tag("ol") +optgroup = Tag("optgroup") +option = Tag("option") +p = Tag("p") +param = Tag("param", True) +picture = Tag("picture") +pre = Tag("pre") +progress = Tag("progress") +q = Tag("q") +rp = Tag("rp") +rt = Tag("rt") +ruby = Tag("ruby") +s = Tag("s") +samp = Tag("samp") +script = Tag("script") +section = Tag("section") +select = Tag("select") +small = Tag("small") +source = Tag("source", True) +span = Tag("span") +strike = Tag("strike") +strong = Tag("strong") +style = Tag("style") +sub = Tag("sub") +summary = Tag("summary") +sup = Tag("sup") +svg = Tag("svg") +table = Tag("table") +tbody = Tag("tbody") +template = Tag("template") +textarea = Tag("textarea") +td = Tag("td") +th = Tag("th") +thead = Tag("thead") +time = Tag("time") +title = Tag("title") +tr = Tag("tr") +track = Tag("track", True) +u = Tag("u") +ul = Tag("ul") +var = Tag("var") +video = Tag("video") +wbr = Tag("wbr") + + +def _render(node: Node, strs: List[str]) -> None: + """ + mutate a list instead of constantly rendering strings + """ + if type(node) is tuple: + if len(node) == 3: + if TYPE_CHECKING: + node = cast(TagTuple, node) + strs.append(node[0]) + for child in node[1]: + _render(child, strs) + strs.append(node[2]) + else: + if TYPE_CHECKING: + node = cast(SafeString, node) + strs.append(node[0]) + elif isinstance(node, str): + strs.append(escape(node)) + elif isinstance(node, Tag): + strs.append(node.rendered) + elif isinstance(node, list): + for n in node: + _render(n, strs) + elif isinstance(node, GeneratorType): + for n in node: + _render(n, strs) + else: + raise TypeError(f"Got unknown type: {type(node)}") + + +def render(*nodes: Node) -> str: + results: List[str] = [] + for node in nodes: + _render(node, results) + + return "".join(results) diff --git a/simple_html/nodes.py b/simple_html/nodes.py deleted file mode 100644 index b0d5ec9..0000000 --- a/simple_html/nodes.py +++ /dev/null @@ -1,164 +0,0 @@ -from typing import Tuple, Union, Dict, List, Generator, Optional - -SafeString = Tuple[str] - - -def safe_string(x: str) -> SafeString: - return (x,) - - -Node = Union[ - str, - SafeString, - "Tag", - "TagTuple", - List["Node"], - Generator["Node", None, None], -] - -TagTuple = Tuple[str, Tuple[Node, ...], str] - - -class Tag: - __slots__ = ('tag_start', 'rendered', "closing_tag", "no_children_close") - - def __init__(self, name: str, self_closing: bool = False) -> None: - self.tag_start = f"<{name}" - if self_closing: - self.closing_tag = "" - self.no_children_close = "/>" - else: - self.closing_tag = f"" - self.no_children_close = f">{self.closing_tag}" - self.rendered = f"{self.tag_start}{self.no_children_close}" - - def __call__( - self, attributes: Dict[str, Optional[str]], *children: Node - ) -> Union[TagTuple, SafeString]: - if attributes: - # in this case this is faster than attrs = "".join([...]) - attrs = "" - for key, val in attributes.items(): - attrs += (" " + key if val is None else f' {key}="{val}"') - - if children: - return f"{self.tag_start}{attrs}>", children, self.closing_tag - else: - return f"{self.tag_start}{attrs}{self.no_children_close}", - return f"{self.tag_start}>", children, self.closing_tag - - -DOCTYPE_HTML5 = safe_string("") - -a = Tag("a") -abbr = Tag("abbr") -address = Tag("address") -area = Tag("area", True) -article = Tag("article") -aside = Tag("aside") -audio = Tag("audio") -b = Tag("b") -base = Tag("base", True) -bdi = Tag("bdi") -bdo = Tag("bdo") -blockquote = Tag("blockquote") -body = Tag("body") -br = Tag("br", True) -button = Tag("button") -canvas = Tag("canvas") -center = Tag("center") -caption = Tag("caption") -cite = Tag("cite") -code = Tag("code") -col = Tag("col") -colgroup = Tag("colgroup") -datalist = Tag("datalist") -dd = Tag("dd") -details = Tag("details") -del_ = Tag("del") -dfn = Tag("dfn") -div = Tag("div") -dl = Tag("dl") -dt = Tag("dt") -em = Tag("em") -embed = Tag("embed", True) -fieldset = Tag("fieldset") -figure = Tag("figure") -figcaption = Tag("figcaption") -footer = Tag("footer") -font = Tag("font") -form = Tag("form") -head = Tag("head") -header = Tag("header") -h1 = Tag("h1") -h2 = Tag("h2") -h3 = Tag("h3") -h4 = Tag("h4") -h5 = Tag("h5") -h6 = Tag("h6") -hr = Tag("hr", True) -html = Tag("html") -i = Tag("i") -iframe = Tag("iframe", True) -img = Tag("img", True) -input_ = Tag("input", True) -ins = Tag("ins") -kbd = Tag("kbd") -label = Tag("label") -legend = Tag("legend") -li = Tag("li") -link = Tag("link", True) -main = Tag("main") -mark = Tag("mark") -marquee = Tag("marquee") -math = Tag("math") -menu = Tag("menu") -menuitem = Tag("menuitem") -meta = Tag("meta", True) -meter = Tag("meter") -nav = Tag("nav") -object_ = Tag("object") -noscript = Tag("noscript") -ol = Tag("ol") -optgroup = Tag("optgroup") -option = Tag("option") -p = Tag("p") -param = Tag("param", True) -picture = Tag("picture") -pre = Tag("pre") -progress = Tag("progress") -q = Tag("q") -rp = Tag("rp") -rt = Tag("rt") -ruby = Tag("ruby") -s = Tag("s") -samp = Tag("samp") -script = Tag("script") -section = Tag("section") -select = Tag("select") -small = Tag("small") -source = Tag("source", True) -span = Tag("span") -strike = Tag("strike") -strong = Tag("strong") -style = Tag("style") -sub = Tag("sub") -summary = Tag("summary") -sup = Tag("sup") -svg = Tag("svg") -table = Tag("table") -tbody = Tag("tbody") -template = Tag("template") -textarea = Tag("textarea") -td = Tag("td") -th = Tag("th") -thead = Tag("thead") -time = Tag("time") -title = Tag("title") -tr = Tag("tr") -track = Tag("track", True) -u = Tag("u") -ul = Tag("ul") -var = Tag("var") -video = Tag("video") -wbr = Tag("wbr") diff --git a/simple_html/render.py b/simple_html/render.py deleted file mode 100644 index aeda80b..0000000 --- a/simple_html/render.py +++ /dev/null @@ -1,43 +0,0 @@ -from html import escape -from types import GeneratorType -from typing import TYPE_CHECKING, cast, List - -from simple_html.nodes import Node, TagTuple, SafeString, Tag - - -def _render(node: Node, strs: List[str]) -> None: - """ - mutate a list instead of constantly rendering strings - """ - if type(node) is tuple: - if len(node) == 3: - if TYPE_CHECKING: - node = cast(TagTuple, node) - strs.append(node[0]) - for child in node[1]: - _render(child, strs) - strs.append(node[2]) - else: - if TYPE_CHECKING: - node = cast(SafeString, node) - strs.append(node[0]) - elif isinstance(node, str): - strs.append(escape(node)) - elif isinstance(node, Tag): - strs.append(node.rendered) - elif isinstance(node, list): - for n in node: - _render(n, strs) - elif isinstance(node, GeneratorType): - for n in node: - _render(n, strs) - else: - raise TypeError(f"Got unknown type: {type(node)}") - - -def render(*nodes: Node) -> str: - results: List[str] = [] - for node in nodes: - _render(node, results) - - return "".join(results) diff --git a/tests/test_render.py b/tests/test_render.py index 2a473a7..f5be2e4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,7 +1,7 @@ import json from typing import Generator -from simple_html.nodes import ( +from simple_html import ( safe_string, a, body, @@ -16,9 +16,10 @@ p, script, span, - Node, DOCTYPE_HTML5, + Node, + DOCTYPE_HTML5, + render ) -from simple_html.render import render def test_renders_no_children() -> None: