├── .gitignore
├── .travis.yml
├── CHANGELOG
├── MANIFEST.in
├── README.rst
├── lys
├── __init__.py
└── tests
│ └── test_lys.py
├── setup.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | /dist/
3 | /*.egg-info
4 | /*.egg
5 | /.eggs/
6 | /.tox/
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: python
3 | python:
4 | - "2.7"
5 | - "3.5"
6 | - "3.6"
7 | - "pypy"
8 | install: pip install tox-travis
9 | script: tox
10 |
--------------------------------------------------------------------------------
/CHANGELOG:
--------------------------------------------------------------------------------
1 | 0.2
2 |
3 | - python 2 support
4 | - LysException instead of asserts
5 | - block people from doing a / b / c instead of a / (b / c)
6 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 |
3 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Lys
2 | ===
3 | .. image:: https://img.shields.io/pypi/v/lys.svg
4 | :target: https://pypi.python.org/pypi/lys
5 | .. image:: https://travis-ci.org/mdamien/lys.svg?branch=master
6 | :target: https://travis-ci.org/mdamien/lys
7 |
8 | *Simple HTML templating for Python*
9 |
10 | .. code:: python
11 |
12 | from lys import L
13 |
14 | print(L.body / (
15 | L.h1 / 'What is love ?',
16 | L.ul / (
17 | L.li / 'Something in the air',
18 | L.li / 'You can\'t catch it',
19 | L.li / (
20 | L.a(href="https://en.wikipedia.org/wiki/Love") / 'Keep trying'
21 | ),
22 | ),
23 | ))
24 |
25 | To install, :code:`pip3 install lys`
26 |
27 | A few more tricks:
28 |
29 | .. code:: python
30 |
31 | # raw() to mark the content as already escaped
32 | from lys import raw
33 | L.p / raw('')
34 |
35 | # attributes '_' are replaced with '-'
36 | L.button(data_id="123") / 'click me'
37 | # =>
38 |
39 | # shortcut to add classes and ids easily
40 | L.button('#magic-button.very-big', onclick='add_it()') / 'Magic !'
41 |
42 | # one easy way to do loops and ifs
43 | (
44 | L.h1 / 'Welcome',
45 | (L.ul / (
46 | 'Try one of our recipes:',
47 | (L.li / (
48 | L.a(href=recipe.link) / recipe.name
49 | ) for recipe in recipes)
50 | ) if len(recipes) > 0 else ''),
51 | )
52 |
53 | **Inspiration** : `pyxl `_, `React `_
54 |
--------------------------------------------------------------------------------
/lys/__init__.py:
--------------------------------------------------------------------------------
1 | """HTML templating with no shame
2 |
3 | >>> from lys import L
4 | >>> str(L.h1 / 'hello world')
5 | 'hello world
'
6 | >>> str(L.hr('.thick')))
7 | '
'
8 | >>> str(L.button(onclick='reverse_entropy()'))
9 | ''
10 | >>> str(L.ul / (
11 | L.li / 'One',
12 | L.li / 'Two',
13 | ))
14 | ''
15 | """
16 | from __future__ import unicode_literals
17 | import html, types, keyword, sys, re
18 | from bs4 import BeautifulSoup
19 |
20 |
21 | __all__ = [
22 | 'L',
23 | 'LysException',
24 | 'raw',
25 | 'render',
26 | 'Node',
27 | ]
28 |
29 |
30 | VOID_TAGS = [
31 | 'area', 'base', 'br', 'col', 'embed', 'hr',
32 | 'img', 'input', 'keygen', 'link', 'meta',
33 | 'param', 'source', 'track', 'wbr'
34 | ]
35 |
36 |
37 | class LysException(Exception):
38 | """Base exception class for all Lys related errors"""
39 |
40 |
41 | def prettify(html):
42 | return BeautifulSoup(html, features="lxml").prettify()
43 |
44 |
45 | def render_attr(key, value):
46 | if not key or ' ' in key:
47 | raise LysException('Invalid attribute name "{}"'.format(key))
48 | key = key.replace('class_', 'class')
49 | if value:
50 | if type(value) is RawNode:
51 | value = str(value)
52 | else:
53 | value = html.escape(value)
54 | return key + '="' + value + '"'
55 | return key
56 |
57 |
58 | def render(node, pretty=False):
59 | """Render a node or a node list to HTML"""
60 | if node is None:
61 | output = ''
62 | elif type(node) is RawNode:
63 | output = node.content
64 | elif type(node) in (tuple, list, types.GeneratorType):
65 | output = ''.join(render(child) for child in node)
66 | elif type(node) is str:
67 | output = html.escape(node)
68 | else:
69 | children_rendered = ''
70 | if node.children:
71 | children_rendered = render(node.children)
72 |
73 | attrs_rendered = ''
74 | if node.attrs:
75 | attrs_rendered = ' ' + ' '.join(
76 | render_attr(k, node.attrs[k]) for k in sorted(node.attrs)
77 | )
78 |
79 | if node.tag in VOID_TAGS:
80 | return '<{tag}{attrs}/>'.format(tag=node.tag, attrs=attrs_rendered)
81 |
82 | output = '<{tag}{attrs}>{children}{tag}>'.format(
83 | tag=node.tag, children=children_rendered, attrs=attrs_rendered)
84 |
85 | return prettify(output) if pretty else output
86 |
87 |
88 | class Node(object):
89 | """An HTML node"""
90 | def __init__(self, tag, attrs=None, children=None):
91 | self.tag = tag
92 | self.attrs = attrs
93 | self.children = children
94 |
95 | def __call__(self, _shortcut=None, **attrs):
96 | """Return a new node with the same tag but new attributes"""
97 | def clean(k, v):
98 | if v and type(v) not in (str, RawNode):
99 | raise LysException('Invalid attribute value "{}"'
100 | ' for key "{}"'.format(v, k))
101 | # allow to use reserved keywords as: class_, for_,..
102 | if k[-1] == '_' and k[:-1] in keyword.kwlist:
103 | k = k[:-1]
104 | # replace all '_' with '-'
105 | return k.replace('_', '-')
106 | attrs = {clean(k, v): v for k, v in attrs.items()}
107 |
108 | # process given shorcut strings like '#my_id.a_class.another_class'
109 | if _shortcut:
110 | def raise_if_bad_name(name, type='class'):
111 | # TODO: regex to verify if valid class name
112 | if ' ' in name or '.' in name or ',' in name:
113 | raise LysException('"{}" is an invalid {} name'.format(name, type))
114 | return name
115 | classes = _shortcut.split('.')
116 | # add #id if there is one
117 | if classes[0] and classes[0][0] == '#':
118 | attrs['id'] = raise_if_bad_name(classes[0][1:], 'id')
119 | classes = classes[1:]
120 | # add classes to the current class
121 | current_classes = attrs.get('class', '').split(' ')
122 | new_classes = [raise_if_bad_name(klass) for klass in current_classes + classes if klass]
123 | if new_classes:
124 | attrs['class'] = ' '.join(new_classes)
125 |
126 | return Node(self.tag, attrs)
127 |
128 | # python 2 compat
129 | def __div__(self, children):
130 | return self.__truediv__(children)
131 |
132 | def __truediv__(self, children):
133 | """Mark a list or one node as the children of this node"""
134 | if self.tag in VOID_TAGS:
135 | raise LysException('<{}> can\'t have children nodes'.format(self.tag))
136 | if self.children and len(self.children) == 1:
137 | self.children = (self.children[0] / children,)
138 | else:
139 | if type(children) not in (tuple, list):
140 | children = (children,)
141 | self.children = children
142 | return self
143 |
144 | def __str__(self):
145 | return render(self)
146 |
147 | def __repr__(self):
148 | return 'Node(tag={}, attrs={}, children={})'.format(self.tag,
149 | self.attrs, self.children)
150 |
151 |
152 | class RawNode(object):
153 | """Node marked as already escaped"""
154 | def __init__(self, content):
155 | self.content = content
156 |
157 | def __str__(self):
158 | return self.content
159 |
160 |
161 | def raw(content):
162 | """Mark a string as already escaped"""
163 | return RawNode(content)
164 |
165 |
166 | class _L:
167 | def __getattr__(self, tag):
168 | return Node(tag)
169 | L = _L()
170 |
--------------------------------------------------------------------------------
/lys/tests/test_lys.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from lys import L, raw, LysException, render
4 |
5 | class Test(TestCase):
6 | def test_hello_world(self):
7 | self.assertEqual(str(L.h1 / 'hello world'), 'hello world
')
8 |
9 | def test_attributes(self):
10 | self.assertEqual(str(L.h1(class_='hello') / 'world'),
11 | 'world
')
12 | self.assertEqual(str(L.input(id='hello', value='world')),
13 | '')
14 | self.assertEqual(str(L.input(what='')),
15 | '')
16 | self.assertEqual(str(L.input(what=None)),
17 | '')
18 | self.assertEqual(str(L.input(data_trigger='666')),
19 | '')
20 |
21 | def test_escaping(self):
22 | self.assertEqual(str(L.div(id='hello & ; " \'')),
23 | '')
24 | self.assertEqual(str(L.h1 / ''),
25 | '<script>alert("h4x0r")</script>
')
26 | self.assertEqual(str(L.button(onclick=raw('alert(\'follow the rabbit\')'))),
27 | '')
28 |
29 | def test_children(self):
30 | self.assertEqual(str(L.body / (
31 | L.ul / (
32 | L.li / 'One',
33 | None,
34 | L.li / 'Two',
35 | L.li / 'Three',
36 | ''
37 | )
38 | )), '')
39 |
40 | def test_shortcut(self):
41 | self.assertEqual(str(L.span('.hello')),
42 | '')
43 | self.assertEqual(str(L.span('.hello.world')),
44 | '')
45 | self.assertEqual(str(L.span('#world.hello')),
46 | '')
47 | self.assertEqual(str(L.span('#what')),
48 | '')
49 |
50 | def test_double_division(self):
51 | self.assertEqual(
52 | str(L.a / L.b / L.c / L.d),
53 | ''
54 | )
55 | self.assertEqual(
56 | str(L.a / L.b / 'C'),
57 | 'C'
58 | )
59 |
60 | def test_pretty_print(self):
61 | self.assertEqual(
62 | render(L.a / L.b / 'C', pretty=True),
63 | '\n \n \n \n C\n \n \n \n'
64 | )
65 |
66 | def test_raise(self):
67 | # no children for void tags
68 | with self.assertRaises(LysException):
69 | L.br / L.p
70 |
71 | # only str or raw() attributes values
72 | with self.assertRaises(LysException):
73 | L.button(data_id=123)
74 |
75 | # invalid shortcuts
76 | with self.assertRaises(LysException):
77 | L.span('.foo.hello world')
78 | with self.assertRaises(LysException):
79 | L.span(',hello')
80 |
81 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import sys
3 | from setuptools import setup
4 |
5 | setup(
6 | name='lys',
7 | version='1.0',
8 | description='Simple HTML templating for Python',
9 | long_description=open('README.rst').read(),
10 | url='http://github.com/mdamien/lys',
11 | author='Damien MARIÉ',
12 | author_email='damien@dam.io',
13 | test_suite='nose.collector',
14 | tests_require=['nose'],
15 | license='MIT',
16 | install_requires=["beautifulsoup4", 'lxml'] + (
17 | ["future"] if sys.version_info < (3,) else []
18 | ),
19 | classifiers=[
20 | 'Development Status :: 5 - Production/Stable',
21 | 'Intended Audience :: Developers',
22 | 'Natural Language :: English',
23 | 'License :: OSI Approved :: MIT License',
24 | 'Programming Language :: Python :: 2',
25 | 'Programming Language :: Python :: 3',
26 | ],
27 | packages=['lys'],
28 | zip_safe=False
29 | )
30 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py27,py35,py36,pypy
3 | [testenv]
4 | commands=python setup.py test
5 |
--------------------------------------------------------------------------------