Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2336731
wip
keithasaurus Aug 15, 2025
c500ce0
wip
keithasaurus Aug 15, 2025
035b766
wip
keithasaurus Aug 15, 2025
c9f0382
wip
keithasaurus Aug 15, 2025
cfd61b3
wip
keithasaurus Aug 15, 2025
3c17083
wip
keithasaurus Aug 15, 2025
b3a39ef
wip
keithasaurus Aug 15, 2025
173833b
wip
keithasaurus Aug 15, 2025
0ae8c1f
wip
keithasaurus Aug 15, 2025
5e70058
wip
keithasaurus Aug 15, 2025
48f0a8b
wip
keithasaurus Aug 15, 2025
def53e5
wip
keithasaurus Aug 15, 2025
2a455d4
wip
keithasaurus Aug 15, 2025
ab3458a
wip
keithasaurus Aug 15, 2025
1b6fe3a
wip
keithasaurus Aug 15, 2025
739fcd3
wip
keithasaurus Aug 15, 2025
ad24fe3
wip
keithasaurus Aug 15, 2025
ba4253d
wip
keithasaurus Aug 15, 2025
ca8c5b7
wip
keithasaurus Aug 15, 2025
3346a5b
wip
keithasaurus Aug 15, 2025
56221f0
wip
keithasaurus Aug 15, 2025
3c6d8e4
wip
keithasaurus Aug 15, 2025
4789856
wip
keithasaurus Aug 15, 2025
7cc827e
39
keithasaurus Aug 15, 2025
be72795
wip
keithasaurus Aug 15, 2025
37cd733
wip
keithasaurus Aug 15, 2025
c9e6a94
wip
keithasaurus Aug 15, 2025
37f11e9
wip
keithasaurus Aug 16, 2025
05fff2e
wip
keithasaurus Aug 18, 2025
1abdd3a
fix merge conflict
keithasaurus Aug 18, 2025
d7d4491
wip
keithasaurus Aug 18, 2025
e2979d4
wi
keithasaurus Aug 19, 2025
bd029b7
wip
keithasaurus Aug 19, 2025
b3dd9b6
wip
keithasaurus Aug 19, 2025
107b927
wip
keithasaurus Aug 19, 2025
43658dd
wip
keithasaurus Aug 19, 2025
d646807
wip
keithasaurus Aug 19, 2025
9f4eff9
wip
keithasaurus Aug 19, 2025
16e7624
wip
keithasaurus Aug 19, 2025
265dced
fix perf
keithasaurus Aug 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ jobs:
run: poetry install
- name: mypy
run: poetry run mypy simple_html
- name: run bench (pure python)
run: poetry run python -m bench.run
- name: mypyc
run: poetry run mypyc simple_html/utils.py
- name: run tests
run: poetry run pytest
- name: run bench (pure python)
- name: run bench (compiled)
run: poetry run python -m bench.run
- name: linting
run: poetry run ruff check
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: build and release

on: [push]

jobs:
build_wheels:
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# macos-13 is an intel runner, macos-14 is apple silicon
os: [ ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-13, macos-14 ]
steps:
- uses: actions/checkout@v4

- name: Build wheels
uses: pypa/cibuildwheel@v3.1.3

- uses: actions/upload-artifact@v4
with:
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./wheelhouse/*.whl

build_sdist:
name: Make SDist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Optional, use if you use setuptools_scm

- name: Build SDist
run: pipx run build --sdist

- uses: actions/upload-artifact@v4
with:
name: cibw-sdist
path: dist/*.tar.gz

upload_all:
needs: [build_sdist, build_wheels]
environment: pypi
permissions:
id-token: write
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
pattern: cibw-*
path: dist
merge-multiple: true

- uses: pypa/gh-action-pypi-publish@release/v1
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Why use it?
- clean syntax
- fully-typed
- speed -- faster even than jinja2
- speed -- faster even than jinja
- zero dependencies
- escaped by default
- usually renders fewer bytes than templating
Expand Down Expand Up @@ -100,7 +100,7 @@ render(
# <div><h1 class="neat-class"><span>cool</span><br/></h1></div>
```
### Strings and Things
Strings, ints, floats, and Decimals are generally rendered as one would expect expect. For security, `str`s are
Strings, ints, floats, and Decimals are generally rendered as one would expect expect. For safety, `str`s are
escaped by default; `SafeString`s can be used to bypass escaping.

```python
Expand Down Expand Up @@ -128,7 +128,7 @@ render(node)
# <div empty-str-attribute="" key-only-attr></div>
```

Attributes are escaped by default -- both keys and values. You can use `SafeString` to bypass, if needed.
String attributes are escaped by default -- both keys and values. You can use `SafeString` to bypass, if needed.

```python
from simple_html import div, render, SafeString
Expand All @@ -144,6 +144,17 @@ render(
# <div <bad>="</also bad>"></div>
```

You can also use `int`, `float`, and `Decimal` instances for attribute values.
```python
from decimal import Decimal
from simple_html import div, render, SafeString

render(
div({"x": 1, "y": 2.3, "z": Decimal('3.45')})
)
# <div x="1" y="2.3" z="3.45"></div>
```

### CSS

You can render inline CSS styles with `render_styles`:
Expand All @@ -168,7 +179,7 @@ render(node)
```

### Collections
You can pass many items as a `Tag`'s children using `*args`, lists and generators:
You can pass many items as a `Tag`'s children using `*args`, lists or generators:
```python
from typing import Generator
from simple_html import div, render, Node, br, p
Expand All @@ -178,7 +189,8 @@ div(
)
# renders to <div>neat<br/><p>cool</p></div>

# same, but no star args

# passing the raw list instead of *args
div(
["neat", br],
p("cool")
Expand Down
12 changes: 10 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ ruff = "0.12.8"


[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
requires = [
"poetry-core>=1.0.0",
"mypy[mypyc]==1.17.1",
]

[tool.mypy]
allow_redefinition = false
Expand All @@ -65,3 +67,9 @@ warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true
warn_unreachable = true

[tool.cibuildwheel]
skip = ["cp38-*", "cp314*"]

[tool.cibuildwheel.windows]
archs = ["AMD64", "x86"]
13 changes: 13 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from setuptools import setup
from mypyc.build import mypycify

ext_modules = mypycify([
"simple_html/utils.py",
])

setup(
name="simple_html",
ext_modules=ext_modules,
packages=["simple_html"],
python_requires=">=3.9",
)
30 changes: 18 additions & 12 deletions simple_html/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from decimal import Decimal
from types import GeneratorType
from typing import Any, Union, Generator, Iterable, Callable, Final
from typing import Any, Union, Generator, Iterable, Callable, Final, TYPE_CHECKING


class SafeString:
Expand Down Expand Up @@ -128,38 +128,43 @@ def __init__(self, name: str, self_closing: bool = False) -> None:

def __call__(
self,
attrs_or_first_child: Union[dict[Union[SafeString, str], Union[str, SafeString, None]], Node],
attrs_or_first_child: Union[dict[Union[SafeString, str], Union[str, SafeString, int, float, Decimal, None]], Node],
*children: Node,
) -> Union[TagTuple, SafeString]:
if isinstance(attrs_or_first_child, dict):
# in this case this tends to be faster than attrs = "".join([...])
tag_start_with_attrs = self.tag_start
attrs: list[str] = []
for key in attrs_or_first_child:
# seems to be faster than using .items()
val: Union[str, SafeString, None] = attrs_or_first_child[key]
val: Union[str, SafeString, int, float, Decimal, None] = attrs_or_first_child[key]

# optimization: a large portion of attribute keys should be
# covered by this check. It allows us to skip escaping
# where it is not needed. Note this is for attribute names only;
# attributes values are always escaped (when they are `str`s)
# key_: str
if key not in _common_safe_attribute_names:
key = (
escape_attribute_key(key)
if isinstance(key, str)
else key.safe_str
)
elif TYPE_CHECKING:
assert isinstance(key, str)

if type(val) is str:
tag_start_with_attrs += f' {key}="{faster_escape(val)}"'
attrs.append(f' {key}="{faster_escape(val)}"')
elif type(val) is SafeString:
tag_start_with_attrs += f' {key}="{val.safe_str}"'
attrs.append(f' {key}="{val.safe_str}"')
elif val is None:
tag_start_with_attrs += f" {key}"
attrs.append(" " + key)
elif isinstance(val, (int, float, Decimal)):
attrs.append(f' {key}="{val}"')

if children:
return f"{tag_start_with_attrs}>", children, self.closing_tag
return self.tag_start + "".join(attrs) + ">", children, self.closing_tag
else:
return SafeString(f"{tag_start_with_attrs}{self.no_children_close}")
return SafeString(self.tag_start + "".join(attrs) + self.no_children_close)
else:
return self.tag_start_no_attrs, (attrs_or_first_child,) + children, self.closing_tag

Expand Down Expand Up @@ -407,7 +412,8 @@ def _render(nodes: Iterable[Node], append_to_list: Callable[[str], None]) -> Non
def render_styles(
styles: dict[Union[str, SafeString], Union[str, int, float, Decimal, SafeString]]
) -> SafeString:
ret = ""
ret: list[str] = []
app = ret.append
for k, v in styles.items():
if k not in _common_safe_css_props:
if isinstance(k, SafeString):
Expand All @@ -421,9 +427,9 @@ def render_styles(
v = faster_escape(v)
# note that ints and floats pass through these condition checks

ret += f"{k}:{v};"
app(f"{k}:{v};")

return SafeString(ret)
return SafeString("".join(ret))


def render(*nodes: Node) -> str:
Expand Down
5 changes: 4 additions & 1 deletion tests/test_simple_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,7 @@ def test_works_for_decimal() -> None:
assert render(div({}, Decimal("-123.456"))) == "<div>-123.456</div>"

def test_tag_repr() -> None:
assert repr(img) == "Tag(name='img', self_closing=True)"
assert repr(img) == "Tag(name='img', self_closing=True)"

def test_render_number_attributes() -> None:
assert render(div({"x": 1, "y": 2.01, "z": Decimal("3.02")})) == '<div x="1" y="2.01" z="3.02"></div>'
Loading