diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94ff39b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: [pull_request] + +jobs: + build: + + runs-on: ubuntu-18.04 + + strategy: + matrix: + python: ["3.6", "3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Install package + run: pip install -e . + - name: Install dependencies + run: pip install -r dev-requirements.txt + - name: Check if blacken-docs needs to be run + run: blacken-docs -l 60 docs/*.rst + - name: Run tests + run: ./runtests diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..fd2d1c3 --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaa3a42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.pyc +*.db +.coverage +MANIFEST +dist/ +build/ +env/ +html/ +htmlcov/ +*.egg-info/ +.tox/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..d084b24 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,8 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +force_alphabetical_sort=True +lines_between_types=0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..913a8a9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2022, Jamie Matthews +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8203df1 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# hotmetal + +**A tiny HTML generator** + +## Installation + +Install from PyPI + + pip install hotmetal + +## What is hotmetal? + +`hotmetal` is a tiny library that lets you generate HTML directly from Python primitive data structures without using any sort of text-based template language. It is an alternative to [Jinja](https://jinja.palletsprojects.com/), [Django templates](https://docs.djangoproject.com/en/4.0/topics/templates/), etc. It is loosely inspired by ideas from [React](https://reactjs.org/), [Mithril](https://mithril.js.org/vnodes.html) and other JavaScript libraries. It is also similar to [Hyperpython](https://github.com/ejplatform/hyperpython), but it's even simpler. It attempts to stay as close as possible to [the HTML spec](https://html.spec.whatwg.org/). + +## How does it work? + +First, read [a quick introduction to HTML](https://html.spec.whatwg.org/#a-quick-introduction-to-html). Go on, it's important. + +An HTML document represents a tree of nodes. Each node (called an "element") consists of exactly three things: a **tag name** (`div`, `p`, `title` etc), a collection of **attributes** (a mapping of keys to values) and a list of **children** (other elements or text nested inside the node). + +So: that's a **tag name** (a string), **attributes** (a mapping), and **children** (a list). + +The simplest way to represent that structure in Python is a tuple of three elements: + +```python +("", {}, []) +``` + +Here's an example of an anchor element (a link) with a text node inside it: + +```python +link_element = ("a", {"href": "somewhere.html"}, ["click me!"]) +``` + +Here's an example of a full HTML document (note that the `DOCTYPE` is missing, we'll come back to that later): + +```python +document = ( + "html", + {"lang": "en"}, + [ + ("head", {}, [("title", {}, ["Sample page"])]), + ( + "body", + {}, + [ + ("h1", {}, ["Sample page"]), + ( + "p", + {}, + ["This is a ", ("a", {"href": "demo.html"}, ["simple"]), " sample"], + ), + ], + ), + ], +) +``` + +Once we've created a structure like this, we can use `hotmetal` to _render_ it into a string: + +```pycon +>>> from hotmetal import render +>>> print(render(document, indent=2)) + + + + Sample page + + + +

+ Sample page +

+

+ This is a + + simple + + sample +

+ + +``` + +In essence, _that's it_. That's all that `hotmetal` does. + +## Components + +But this all looks pretty fiddly, right? If you always needed to painstakingly assemble a huge tree of nested tuples to render every page on your web app, that would be annoyingly verbose and difficult to read. + +So here's the clever bit: instead, **write functions with meaningful names that return element nodes, and build your web app with those**. Let's call these functions that return nodes _components_ (if you're familiar with React you might see where this is going). + +```python +def menu_item(href, text): + return ("li", {}, [("a", {"href": href}, [text])]) + + +def menu(items): + return ("ul", {}, [menu_item(href, text) for href, text in items]) + + +menu_node = menu( + [ + ("/home/", "Home"), + ("/blog/", "Blog"), + ] +) +``` + +... and so on, right down to the ``. + +For a good explanation of some useful patterns to use when composing markup in this way, have a read of the React docs on [Composition vs Inheritance](https://reactjs.org/docs/composition-vs-inheritance.html). The concepts should be directly transferable. + +## Fragments + +Earlier, we brushed over the fact that the example we used was missing its `DOCTYPE`. That's because the root of an HTML document is really two things: the `` element itself, and before it [the `DOCTYPE` preamble](https://html.spec.whatwg.org/#the-doctype). We can express this structure in `hotmetal` by using a _fragment_. Again, the [same concept exists in React](https://reactjs.org/docs/fragments.html), but the syntax is simpler in `hotmetal`. Just use a node with an empty tag name and attributes: + +```python +from hotmetal import safe + + +document = ( + "", + {}, + [ + safe(""), + ( + "html", + {"lang": "en"}, + [...], # head, body etc + ), + ], +) +``` + +When rendering, `hotmetal` will "optimise away" the empty node, leaving only the two consecutive child nodes. This is also often useful during component composition: you might create a component that accepts a single child node as an argument, but you can still pass it a list of multiple nodes by wrapping them in a fragment. + +But what's that `safe` thing about? + +## Escaping + +You have to be careful when generating HTML. If any part of your markup or text content can be supplied by a user (which is common in web applications), you may be vulnerable to cross-site scripting (XSS) attacks. The Django docs have [a good explanation of why this is important](https://docs.djangoproject.com/en/4.0/ref/templates/language/#automatic-html-escaping-1). + +By default, `hotmetal` escapes _every_ string it renders using Python's built-in [`html.escape`](https://docs.python.org/3/library/html.html#html.escape) functionality. So you can add user-generated content to your documents without worrying. + +If you want to add some raw markup that you _know_ is safe (some hand-crafted HTML, or the rendered output of another templating system that already escapes unsafe content) then you can wrap those strings in the `safe` function. See the above section for an example. + +## Context + +The [React docs on context](https://reactjs.org/docs/context.html) say: + +> Context provides a way to pass data through the component tree without having to pass props down manually at every level. + +Exactly the same concept exists in `hotmetal`, but again the implementation is a little simpler. + +If you're familiar with the concept of context in Django or Jinja templates, this is _not quite the same thing_. In those languages, context is the _only_ way to pass variables into a template. In `hotmetal` there's no need to do this: you can just create component functions that accept arguments: + +```python +def header(title): + return ("h1", {}, [title]) +``` + +So where would you use context? Just like in React: + +> Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult. + +A good example might be if you need to access the `request` object or the currently logged in user somewhere deep in your component tree, but you don't want to explicitly pass it down through the component hierarchy from the root. + +To use context in `hotmetal`, you pass a dictionary to the `render` function: + +```python +render(some_node, context={"logged_in_user_email": "hello@example.com"}) +``` + +To access the context from inside the tree, replace any node in the tree with a _callable_ (a named function or a lambda) that returns that node. During rendering, `hotmetal` will call your function, passing the `context` dictionary as its single argument. + +```python +def current_user_panel(): + return lambda context: ( + "p", + {}, + [f"Hello, {context['logged_in_user_email']}"], + ) +``` + +## Indentation control + +The `render` function takes an `indent` argument, which is a integer used to control how many spaces are used as indentation in the generated HTML. The default is 0, meaning the entire HTML string will be returned on a single line. You may wish to use (say) `indent=2` for development, and `indent=0` for production (essentially minifying your HTML). diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..cc5eb5a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,4 @@ +black==22.3.0 +blacken-docs==1.12.1 +flake8==4.0.1 +isort==5.10.1 diff --git a/format b/format new file mode 100755 index 0000000..58d64e9 --- /dev/null +++ b/format @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +black hotmetal tests +isort hotmetal tests +blacken-docs README.md diff --git a/hotmetal/__init__.py b/hotmetal/__init__.py new file mode 100644 index 0000000..d0a5c30 --- /dev/null +++ b/hotmetal/__init__.py @@ -0,0 +1,68 @@ +import html + +# https://html.spec.whatwg.org/#void-elements +VOID_ELEMENTS = set( + "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(",") +) + + +class safe(str): + pass + + +def esc(string): + return string if isinstance(string, safe) else safe(html.escape(string)) + + +def _render(*, tree_or_text_or_callable, context, indent, level, is_root): + breaker = "\n" if indent else "" + indenter = " " * indent * level + tree_or_text = ( + tree_or_text_or_callable(context) + if callable(tree_or_text_or_callable) + else tree_or_text_or_callable + ) + if not tree_or_text: + return "" + if isinstance(tree_or_text, str): + return f"{breaker}{indenter}{esc(tree_or_text)}" + tag, attrs, children = tree_or_text + breaker = breaker if tag else "" + attrs = ( + ( + " " + + " ".join( + esc(k) if v == "" else f'{esc(k)}="{esc(v)}"' for k, v in attrs.items() + ) + ) + if attrs + else "" + ) + is_void = tag.lower() in VOID_ELEMENTS + self_closer = " /" if is_void else "" + opener = f"<{esc(tag)}{attrs}{self_closer}>" if tag else "" + closer = f"" if (tag and not is_void) else "" + next_level = level + 1 if tag else level + children = "".join( + _render( + tree_or_text_or_callable=child, + context=context, + indent=indent, + level=next_level, + is_root=False, + ) + for child in children + ) + contents = f"{children}{breaker}{indenter}" if children else "" + rendered = f"{breaker}{indenter}{opener}{contents}{closer}" + return rendered.strip("\n") if is_root else rendered + + +def render(node, context=None, indent=0): + return _render( + tree_or_text_or_callable=node, + context=context or {}, + indent=indent, + level=0, + is_root=True, + ) diff --git a/runtests b/runtests new file mode 100755 index 0000000..d3b021f --- /dev/null +++ b/runtests @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +black --check hotmetal tests +flake8 hotmetal tests +isort --check --diff hotmetal tests +python -m unittest $@ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..53dcf67 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +extend_ignore=E128,E501,W503 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e5de4ea --- /dev/null +++ b/setup.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import print_function +from setuptools import setup +import re +import os + + +name = "hotmetal" +package = "hotmetal" +description = "" +url = "https://github.com/j4mie/hotmetal" +author = "Jamie Matthews" +author_email = "jamie@mtth.org" +license = "BSD" + +with open("README.md") as f: + readme = f.read() + + +def get_version(package): + """ + Return package version as listed in `__version__` in `init.py`. + """ + init_py = open(os.path.join(package, "__init__.py")).read() + return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group( + 1 + ) + + +def get_packages(package): + """ + Return root package and all sub-packages. + """ + return [ + dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py")) + ] + + +def get_package_data(package): + """ + Return all files under the root package, that are not in a + package themselves. + """ + walk = [ + (dirpath.replace(package + os.sep, "", 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if not os.path.exists(os.path.join(dirpath, "__init__.py")) + ] + + filepaths = [] + for base, filenames in walk: + filepaths.extend([os.path.join(base, filename) for filename in filenames]) + return {package: filepaths} + + +setup( + name=name, + version=get_version(package), + url=url, + license=license, + description=description, + long_description=readme, + long_description_content_type="text/markdown", + author=author, + author_email=author_email, + packages=get_packages(package), + package_data=get_package_data(package), + python_requires=">=3.6", + install_requires=[], + project_urls={ + "Changelog": "https://github.com/j4mie/hotmetal/releases", + "Issues": "https://github.com/j4mie/hotmetal/issues", + } +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..93bdcd0 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,147 @@ +from hotmetal import render, safe, VOID_ELEMENTS +from textwrap import dedent +from unittest import TestCase + + +class EmptyNodeTestCase(TestCase): + def test_empty_node(self): + node = ("", {}, []) + self.assertEqual(render(node), "") + + def test_falsy_node(self): + for falsy in [None, False, "", [], {}, ()]: + self.assertEqual(render(falsy), "") + + +class SimpleNodeTestCase(TestCase): + def test_div(self): + node = ("div", {}, []) + self.assertEqual(render(node), "
") + + +class SelfClosingNodeTestCase(TestCase): + def test_void_elements(self): + for tag in VOID_ELEMENTS: + node = (tag, {}, []) + self.assertEqual(render(node), f"<{tag} />") + + +class AttributeTestCase(TestCase): + def test_attrs(self): + node = ("div", {"class": "some-class"}, []) + self.assertEqual(render(node), '
') + + def test_empty_attr(self): + """ + "The value, along with the "=" character, can be omitted altogether + if the value is the empty string." + - https://html.spec.whatwg.org/#a-quick-introduction-to-html + """ + node = ("input", {"disabled": ""}, []) + self.assertEqual(render(node), "") + + +class TestNodeTestCase(TestCase): + def test_text(self): + node = "test" + self.assertEqual(render(node), "test") + + +class ChildrenTestCase(TestCase): + def test_text_child(self): + node = ("p", {}, ["test"]) + self.assertEqual(render(node), "

test

") + + def test_nested_children(self): + node = ("p", {}, [("strong", {}, "test")]) + self.assertEqual(render(node), "

test

") + + def test_multiple_children(self): + node = ("p", {}, [("strong", {}, "test1"), ("em", {}, ["test2"])]) + self.assertEqual(render(node), "

test1test2

") + + def test_mixed_node_and_text_children(self): + node = ("p", {}, [("strong", {}, "test1"), "hello"]) + self.assertEqual(render(node), "

test1hello

") + + +class EscapingTestCase(TestCase): + def test_text_escaping(self): + node = ("p", {}, "") + self.assertEqual(render(node), "

<script></script>

") + + def test_tag_escaping(self): + node = ("alert('hi')", {}, []) + self.assertEqual( + render(node), "" + ) + + def test_attr_value_escaping(self): + node = ("div", {"onclick": "alert('hi')"}, []) + self.assertEqual(render(node), '
') + + def test_attr_key_escaping(self): + node = ("div", {"on('click')": "hi"}, []) + self.assertEqual(render(node), '
') + + def test_safe(self): + node = ("div", {}, [safe("

hello

")]) + self.assertEqual(render(node), "

hello

") + + +class IndentationTestCase(TestCase): + def test_indentation_2(self): + node = ("div", {"class": "test"}, [("div", {}, ["hello"])]) + self.assertEqual( + render(node, indent=2), + dedent( + """\ +
+
+ hello +
+
""" + ), + ) + + def test_indentation_4(self): + node = ("div", {"class": "test"}, [("div", {}, ["hello"])]) + self.assertEqual( + render(node, indent=4), + dedent( + """\ +
+
+ hello +
+
""" + ), + ) + + def test_indentation_with_root_fragment(self): + node = ( + "", + {}, + [ + ("div", {}, ["hello1"]), + ("div", {}, ["hello2"]), + ], + ) + self.assertEqual( + render(node, indent=2), + dedent( + """\ +
+ hello1 +
+
+ hello2 +
""" + ), + ) + + +class ContextTestCase(TestCase): + def test_context(self): + node = lambda context: ("div", {}, [context["message"]]) # noqa: E731 + self.assertEqual(render(node, context={"message": "test"}), "
test
")