From 74536d067660d6be4d8d79191834913bc9ac5d96 Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 20:17:18 -0800 Subject: [PATCH 1/6] wip --- simple_html/__init__.py | 70 ++++++++++++++++++++++++++++++++++++--- tests/test_simple_html.py | 47 +++++++++++++++++--------- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/simple_html/__init__.py b/simple_html/__init__.py index fbae2c4..aec84c2 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -1,6 +1,6 @@ from html import escape from types import GeneratorType -from typing import Tuple, Union, Dict, List, FrozenSet, Generator, Iterable +from typing import Tuple, Union, Dict, List, FrozenSet, Generator, Iterable, Any class SafeString: @@ -12,6 +12,12 @@ def __init__(self, safe_str: str) -> None: def __hash__(self) -> int: return hash(f"SafeString__{self.safe_str}") + def __eq__(self, other: Any) -> bool: + return isinstance(other, SafeString) and other.safe_str == self.safe_str + + def __repr__(self) -> str: + return f"SafeString(safe_str='{self.safe_str}')" + Node = Union[ str, @@ -102,9 +108,9 @@ def __init__(self, name: str, self_closing: bool = False) -> None: self.rendered = f"{self.tag_start}{self.no_children_close}" def __call__( - self, - attributes: Dict[Union[SafeString, str], Union[str, SafeString, None]], - *children: Node, + self, + attributes: Dict[Union[SafeString, str], Union[str, SafeString, None]], + *children: Node, ) -> TagTuple: if attributes: # in this case this is faster than attrs = "".join([...]) @@ -273,6 +279,62 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None: raise TypeError(f"Got unknown type: {type(node)}") +_common_safe_css_props = frozenset({ + "color", "border", "margin", "font-style", "transform", "background-color", "align-content", "align-items", + "align-self", "all", "animation", "animation-delay", "animation-direction", "animation-duration", + "animation-fill-mode", "animation-iteration-count", "animation-name", "animation-play-state", + "animation-timing-function", "backface-visibility", "background", "background-attachment", "background-blend-mode", + "background-clip", "background-color", "background-image", "background-origin", "background-position", + "background-repeat", "background-size", "border", "border-bottom", "border-bottom-color", + "border-bottom-left-radius", "border-bottom-right-radius", "border-bottom-style", "border-bottom-width", + "border-collapse", "border-color", "border-image", "border-image-outset", "border-image-repeat", + "border-image-slice", "border-image-source", "border-image-width", "border-left", "border-left-color", + "border-left-style", "border-left-width", "border-radius", "border-right", "border-right-color", + "border-right-style", "border-right-width", "border-spacing", "border-style", "border-top", "border-top-color", + "border-top-left-radius", "border-top-right-radius", "border-top-style", "border-top-width", "border-width", + "bottom", "box-shadow", "box-sizing", "caption-side", "caret-color", "@charset", "clear", "clip", "clip-path", + "color", "column-count", "column-fill", "column-gap", "column-rule", "column-rule-color", "column-rule-style", + "column-rule-width", "column-span", "column-width", "columns", "content", "counter-increment", "counter-reset", + "cursor", "direction", "display", "empty-cells", "filter", "flex", "flex-basis", "flex-direction", "flex-flow", + "flex-grow", "flex-shrink", "flex-wrap", "float", "font", "@font-face", "font-family", "font-kerning", "font-size", + "font-size-adjust", "font-stretch", "font-style", "font-variant", "font-weight", "grid", "grid-area", + "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", "grid-column", "grid-column-end", "grid-column-gap", + "grid-column-start", "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", "grid-row-start", "grid-template", + "grid-template-areas", "grid-template-columns", "grid-template-rows", "height", "hyphens", "@import", + "justify-content", "@keyframes", "left", "letter-spacing", "line-height", "list-style", "list-style-image", + "list-style-position", "list-style-type", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", + "max-height", "max-width", "@media", "min-height", "min-width", "object-fit", "object-position", "opacity", "order", + "outline", "outline-color", "outline-offset", "outline-style", "outline-width", "overflow", "overflow-x", + "overflow-y", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top", "page-break-after", + "page-break-before", "page-break-inside", "perspective", "perspective-origin", "pointer-events", "position", + "quotes", "right", "scroll-behavior", "table-layout", "text-align", "text-align-last", "text-decoration", + "text-decoration-color", "text-decoration-line", "text-decoration-style", "text-indent", "text-justify", + "text-overflow", "text-shadow", "text-transform", "top", "transform", "transform-origin", "transform-style", + "transition", "transition-delay", "transition-duration", "transition-property", "transition-timing-function", + "user-select", "vertical-align", "visibility", "white-space", "width", "word-break", "word-spacing", "word-wrap", + "writing-mode", "z-index" +}) + + +def render_styles(styles: dict[str | SafeString, str | SafeString]) -> SafeString: + ret = "" + for k, v in styles.items(): + if k not in _common_safe_css_props: + if isinstance(k, SafeString): + k = k.safe_str + else: + k = escape(k, True) + + if isinstance(v, SafeString): + v = v.safe_str + else: + v = escape(v, True) + + ret += f"{k}:{v};" + + return SafeString(ret) + + def render(*nodes: Node) -> str: results: List[str] = [] _render(nodes, results) diff --git a/tests/test_simple_html.py b/tests/test_simple_html.py index 0d29484..421808e 100644 --- a/tests/test_simple_html.py +++ b/tests/test_simple_html.py @@ -19,7 +19,7 @@ Node, DOCTYPE_HTML5, render, - escape_attribute_key, + escape_attribute_key, render_styles, ) @@ -114,8 +114,8 @@ def test_kw_attributes() -> None: node = div({"class": "first", "name": "some_name", "style": "color:blue;"}, "okok") assert ( - render(node) - == '<div class="first" name="some_name" style="color:blue;">okok</div>' + render(node) + == '<div class="first" name="some_name" style="color:blue;">okok</div>' ) @@ -156,8 +156,8 @@ def test_render_kw_attribute_with_none() -> None: def test_can_render_empty() -> None: assert render([]) == "" assert ( - render(div({}, [], "hello ", [], span({}, "World!"), [])) - == "<div>hello <span>World!</span></div>" + render(div({}, [], "hello ", [], span({}, "World!"), [])) + == "<div>hello <span>World!</span></div>" ) @@ -175,24 +175,24 @@ def test_escape_key() -> None: assert escape_attribute_key("=") == "=" assert escape_attribute_key("`") == "`" assert ( - escape_attribute_key("something with spaces") - == "something with spaces" + escape_attribute_key("something with spaces") + == "something with spaces" ) def test_render_with_escaped_attributes() -> None: assert ( - render(div({'onmousenter="alert(1)" noop': "1"})) - == '<div onmousenter="alert(1)" noop="1"></div>' + render(div({'onmousenter="alert(1)" noop': "1"})) + == '<div onmousenter="alert(1)" noop="1"></div>' ) assert ( - render(span({"<script>\"</script>": "\">"})) - == '<span <script>"</script>="">"></span>' + render(span({"<script>\"</script>": "\">"})) + == '<span <script>"</script>="">"></span>' ) # vals and keys escape slightly differently assert ( - render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'})) - == '<div onmousenter="alert(1)" noop="onmousenter="alert(1)" noop"></div>' + render(div({'onmousenter="alert(1)" noop': 'onmousenter="alert(1)" noop'})) + == '<div onmousenter="alert(1)" noop="onmousenter="alert(1)" noop"></div>' ) @@ -200,6 +200,23 @@ def test_render_with_safestring_attributes() -> None: bad_key = 'onmousenter="alert(1)" noop' bad_val = "<script></script>" assert ( - render(div({SafeString(bad_key): SafeString(bad_val)})) - == f'<div {bad_key}="{bad_val}"></div>' + render(div({SafeString(bad_key): SafeString(bad_val)})) + == f'<div {bad_key}="{bad_val}"></div>' ) + + +def test_safestring_repr() -> None: + assert repr(SafeString("abc123")) == "SafeString(safe_str='abc123')" + + +def test_safe_string_eq() -> None: + assert "abc123" != SafeString("abc123") + assert SafeString("a") != SafeString("abc123") + assert SafeString("abc123") == SafeString("abc123") + + +def test_render_styles() -> None: + assert render_styles({}) == SafeString("") + assert render_styles({"abc": "123"}) == SafeString("abc:123;") + assert render_styles({"padding": "0", + "margin": "0 10"}) == SafeString("padding:0;margin:0 10;") From 3fa8a86ac332b2549c2fb9a5d890d93428c29e99 Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 20:58:16 -0800 Subject: [PATCH 2/6] tests and docs --- README.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- simple_html/__init__.py | 5 +++-- tests/test_simple_html.py | 13 +++++++++++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f3a3c50..dabcc38 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,25 @@ render( # <div empty-str-attribute="" key-only-attr></div> ``` +You can render inline css styles with `render_styles`: +```python +from simple_html import div, render, render_styles + +node = div({"style": render_styles({"min-width": "25px"})}, + "cool") + +render(node) # <div style="min-width:25px;">cool</div> + +# ints and floats can be values for styles +styles = render_styles({"padding": 0, "flex-grow": 0.6}) + +render( + div({"style": styles}, + "wow") +) +# <div style="padding:0;flex-grow:0.6;">wow</div> +``` + Lists and generators are both valid collections of nodes: ```python diff --git a/pyproject.toml b/pyproject.toml index 284162d..e8a126e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "simple-html" -version = "1.1.1" +version = "1.2.0" readme = "README.md" description = "Template-less html rendering in Python" authors = ["Keith Philpott <fakekeith@example.org>"] diff --git a/simple_html/__init__.py b/simple_html/__init__.py index aec84c2..f0a2007 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -316,7 +316,7 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None: }) -def render_styles(styles: dict[str | SafeString, str | SafeString]) -> SafeString: +def render_styles(styles: dict[str | SafeString, str | int | float | SafeString]) -> SafeString: ret = "" for k, v in styles.items(): if k not in _common_safe_css_props: @@ -327,8 +327,9 @@ def render_styles(styles: dict[str | SafeString, str | SafeString]) -> SafeStrin if isinstance(v, SafeString): v = v.safe_str - else: + elif isinstance(v, str): v = escape(v, True) + # note that ints and floats pass through these condition checks ret += f"{k}:{v};" diff --git a/tests/test_simple_html.py b/tests/test_simple_html.py index 421808e..c75178f 100644 --- a/tests/test_simple_html.py +++ b/tests/test_simple_html.py @@ -217,6 +217,15 @@ def test_safe_string_eq() -> None: def test_render_styles() -> None: assert render_styles({}) == SafeString("") - assert render_styles({"abc": "123"}) == SafeString("abc:123;") - assert render_styles({"padding": "0", + assert render_styles({"abc": 123.45}) == SafeString("abc:123.45;") + assert render_styles({"padding": 0, "margin": "0 10"}) == SafeString("padding:0;margin:0 10;") + + assert render(div({"style": render_styles({"min-width": "25px"})}, + "cool")) == '<div style="min-width:25px;">cool</div>' + + +def test_render_styles_escapes() -> None: + assert render_styles({'"><': "><>\""}) == SafeString( + safe_str='"><:><>";' + ) From 197cc8995dc964fc7a005ae9fada8b07331ba06b Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 21:01:46 -0800 Subject: [PATCH 3/6] wip --- simple_html/__init__.py | 260 +++++++++++++++++++++++++++++++++------- 1 file changed, 219 insertions(+), 41 deletions(-) diff --git a/simple_html/__init__.py b/simple_html/__init__.py index f0a2007..3f98bf5 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -108,9 +108,9 @@ def __init__(self, name: str, self_closing: bool = False) -> None: self.rendered = f"{self.tag_start}{self.no_children_close}" def __call__( - self, - attributes: Dict[Union[SafeString, str], Union[str, SafeString, None]], - *children: Node, + self, + attributes: Dict[Union[SafeString, str], Union[str, SafeString, None]], + *children: Node, ) -> TagTuple: if attributes: # in this case this is faster than attrs = "".join([...]) @@ -279,44 +279,222 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None: raise TypeError(f"Got unknown type: {type(node)}") -_common_safe_css_props = frozenset({ - "color", "border", "margin", "font-style", "transform", "background-color", "align-content", "align-items", - "align-self", "all", "animation", "animation-delay", "animation-direction", "animation-duration", - "animation-fill-mode", "animation-iteration-count", "animation-name", "animation-play-state", - "animation-timing-function", "backface-visibility", "background", "background-attachment", "background-blend-mode", - "background-clip", "background-color", "background-image", "background-origin", "background-position", - "background-repeat", "background-size", "border", "border-bottom", "border-bottom-color", - "border-bottom-left-radius", "border-bottom-right-radius", "border-bottom-style", "border-bottom-width", - "border-collapse", "border-color", "border-image", "border-image-outset", "border-image-repeat", - "border-image-slice", "border-image-source", "border-image-width", "border-left", "border-left-color", - "border-left-style", "border-left-width", "border-radius", "border-right", "border-right-color", - "border-right-style", "border-right-width", "border-spacing", "border-style", "border-top", "border-top-color", - "border-top-left-radius", "border-top-right-radius", "border-top-style", "border-top-width", "border-width", - "bottom", "box-shadow", "box-sizing", "caption-side", "caret-color", "@charset", "clear", "clip", "clip-path", - "color", "column-count", "column-fill", "column-gap", "column-rule", "column-rule-color", "column-rule-style", - "column-rule-width", "column-span", "column-width", "columns", "content", "counter-increment", "counter-reset", - "cursor", "direction", "display", "empty-cells", "filter", "flex", "flex-basis", "flex-direction", "flex-flow", - "flex-grow", "flex-shrink", "flex-wrap", "float", "font", "@font-face", "font-family", "font-kerning", "font-size", - "font-size-adjust", "font-stretch", "font-style", "font-variant", "font-weight", "grid", "grid-area", - "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", "grid-column", "grid-column-end", "grid-column-gap", - "grid-column-start", "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", "grid-row-start", "grid-template", - "grid-template-areas", "grid-template-columns", "grid-template-rows", "height", "hyphens", "@import", - "justify-content", "@keyframes", "left", "letter-spacing", "line-height", "list-style", "list-style-image", - "list-style-position", "list-style-type", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", - "max-height", "max-width", "@media", "min-height", "min-width", "object-fit", "object-position", "opacity", "order", - "outline", "outline-color", "outline-offset", "outline-style", "outline-width", "overflow", "overflow-x", - "overflow-y", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top", "page-break-after", - "page-break-before", "page-break-inside", "perspective", "perspective-origin", "pointer-events", "position", - "quotes", "right", "scroll-behavior", "table-layout", "text-align", "text-align-last", "text-decoration", - "text-decoration-color", "text-decoration-line", "text-decoration-style", "text-indent", "text-justify", - "text-overflow", "text-shadow", "text-transform", "top", "transform", "transform-origin", "transform-style", - "transition", "transition-delay", "transition-duration", "transition-property", "transition-timing-function", - "user-select", "vertical-align", "visibility", "white-space", "width", "word-break", "word-spacing", "word-wrap", - "writing-mode", "z-index" -}) - - -def render_styles(styles: dict[str | SafeString, str | int | float | SafeString]) -> SafeString: +_common_safe_css_props = frozenset( + { + "color", + "border", + "margin", + "font-style", + "transform", + "background-color", + "align-content", + "align-items", + "align-self", + "all", + "animation", + "animation-delay", + "animation-direction", + "animation-duration", + "animation-fill-mode", + "animation-iteration-count", + "animation-name", + "animation-play-state", + "animation-timing-function", + "backface-visibility", + "background", + "background-attachment", + "background-blend-mode", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-repeat", + "background-size", + "border", + "border-bottom", + "border-bottom-color", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-style", + "border-bottom-width", + "border-collapse", + "border-color", + "border-image", + "border-image-outset", + "border-image-repeat", + "border-image-slice", + "border-image-source", + "border-image-width", + "border-left", + "border-left-color", + "border-left-style", + "border-left-width", + "border-radius", + "border-right", + "border-right-color", + "border-right-style", + "border-right-width", + "border-spacing", + "border-style", + "border-top", + "border-top-color", + "border-top-left-radius", + "border-top-right-radius", + "border-top-style", + "border-top-width", + "border-width", + "bottom", + "box-shadow", + "box-sizing", + "caption-side", + "caret-color", + "@charset", + "clear", + "clip", + "clip-path", + "color", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-color", + "column-rule-style", + "column-rule-width", + "column-span", + "column-width", + "columns", + "content", + "counter-increment", + "counter-reset", + "cursor", + "direction", + "display", + "empty-cells", + "filter", + "flex", + "flex-basis", + "flex-direction", + "flex-flow", + "flex-grow", + "flex-shrink", + "flex-wrap", + "float", + "font", + "@font-face", + "font-family", + "font-kerning", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-variant", + "font-weight", + "grid", + "grid-area", + "grid-auto-columns", + "grid-auto-flow", + "grid-auto-rows", + "grid-column", + "grid-column-end", + "grid-column-gap", + "grid-column-start", + "grid-gap", + "grid-row", + "grid-row-end", + "grid-row-gap", + "grid-row-start", + "grid-template", + "grid-template-areas", + "grid-template-columns", + "grid-template-rows", + "height", + "hyphens", + "@import", + "justify-content", + "@keyframes", + "left", + "letter-spacing", + "line-height", + "list-style", + "list-style-image", + "list-style-position", + "list-style-type", + "margin", + "margin-bottom", + "margin-left", + "margin-right", + "margin-top", + "max-height", + "max-width", + "@media", + "min-height", + "min-width", + "object-fit", + "object-position", + "opacity", + "order", + "outline", + "outline-color", + "outline-offset", + "outline-style", + "outline-width", + "overflow", + "overflow-x", + "overflow-y", + "padding", + "padding-bottom", + "padding-left", + "padding-right", + "padding-top", + "page-break-after", + "page-break-before", + "page-break-inside", + "perspective", + "perspective-origin", + "pointer-events", + "position", + "quotes", + "right", + "scroll-behavior", + "table-layout", + "text-align", + "text-align-last", + "text-decoration", + "text-decoration-color", + "text-decoration-line", + "text-decoration-style", + "text-indent", + "text-justify", + "text-overflow", + "text-shadow", + "text-transform", + "top", + "transform", + "transform-origin", + "transform-style", + "transition", + "transition-delay", + "transition-duration", + "transition-property", + "transition-timing-function", + "user-select", + "vertical-align", + "visibility", + "white-space", + "width", + "word-break", + "word-spacing", + "word-wrap", + "writing-mode", + "z-index", + } +) + + +def render_styles( + styles: dict[Union[str, SafeString], Union[str | int | float | SafeString]] +) -> SafeString: ret = "" for k, v in styles.items(): if k not in _common_safe_css_props: From efa1a29073612e66c4d1c5ec914fd1661c77a74d Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 21:02:53 -0800 Subject: [PATCH 4/6] fix --- simple_html/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simple_html/__init__.py b/simple_html/__init__.py index 3f98bf5..c93017c 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -493,7 +493,7 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None: def render_styles( - styles: dict[Union[str, SafeString], Union[str | int | float | SafeString]] + styles: dict[Union[str, SafeString], Union[str, int, float, SafeString]] ) -> SafeString: ret = "" for k, v in styles.items(): From bf25960247fd7cff4078fe80b636c0d65d60b775 Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 21:10:59 -0800 Subject: [PATCH 5/6] fix Dict type --- simple_html/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simple_html/__init__.py b/simple_html/__init__.py index c93017c..b36e311 100644 --- a/simple_html/__init__.py +++ b/simple_html/__init__.py @@ -493,7 +493,7 @@ def _render(nodes: Iterable[Node], strs: List[str]) -> None: def render_styles( - styles: dict[Union[str, SafeString], Union[str, int, float, SafeString]] + styles: Dict[Union[str, SafeString], Union[str, int, float, SafeString]] ) -> SafeString: ret = "" for k, v in styles.items(): From fe6395d912ea7912203179baa4d19bf8eff990b4 Mon Sep 17 00:00:00 2001 From: Keith Philpott <keith.philpott@icloud.com> Date: Thu, 7 Dec 2023 21:23:36 -0800 Subject: [PATCH 6/6] update docs --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dabcc38..d589b16 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,16 @@ You can render inline css styles with `render_styles`: ```python from simple_html import div, render, render_styles -node = div({"style": render_styles({"min-width": "25px"})}, - "cool") +styles = render_styles({"min-width": "25px"}) + +render( + div({"style": styles}, + "cool") +) +# <div style="min-width:25px;">cool</div> -render(node) # <div style="min-width:25px;">cool</div> -# ints and floats can be values for styles +# ints and floats are legal values styles = render_styles({"padding": 0, "flex-grow": 0.6}) render(