├── MANIFEST.in ├── chevron ├── metadata.py ├── __init__.py ├── main.py ├── tokenizer.py └── renderer.py ├── tests ├── unicode.mustache ├── partial.mustache ├── data.json ├── test-partials-disabled.rendered ├── test.rendered └── test.mustache ├── .gitmodules ├── .coveragerc ├── __init__.py ├── tox.ini ├── .gitignore ├── LICENSE ├── setup.py ├── .travis.yml ├── benchmark.py ├── README.md └── test_spec.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /chevron/metadata.py: -------------------------------------------------------------------------------- 1 | version = '0.13.1' 2 | -------------------------------------------------------------------------------- /tests/unicode.mustache: -------------------------------------------------------------------------------- 1 | (╯°□°)╯︵ ┻━┻ 2 | -------------------------------------------------------------------------------- /tests/partial.mustache: -------------------------------------------------------------------------------- 1 | this is a partial{{excited}} 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "spec"] 2 | path = spec 3 | url = https://github.com/mustache/spec 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | if __name__ == .__main__.: 4 | def cli_main 5 | 6 | \# python 2 7 | \# not tested 8 | -------------------------------------------------------------------------------- /chevron/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import main, cli_main 2 | from .renderer import render 3 | from .tokenizer import ChevronError 4 | 5 | __all__ = ['main', 'render', 'cli_main', 'ChevronError'] 6 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .chevron.main import main, cli_main 2 | from .chevron.renderer import render 3 | from .chevron.tokenizer import ChevronError 4 | 5 | __all__ = ['main', 'render', 'cli_main', 'ChevronError'] 6 | -------------------------------------------------------------------------------- /tests/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "test", 3 | "html_escaped": "< > & \"", 4 | "true": true, 5 | "false": false, 6 | "list": [ 7 | {"i": 1, "v": "one"}, 8 | {"i": 2, "v": "two"}, 9 | {"i": 3, "v": "three"} 10 | ], 11 | 12 | "scope": { 13 | "test": "new test" 14 | }, 15 | "unicode": "(╯°□°)╯︵ ┻━┻", 16 | "unicode_html": "(╯°□°)╯︵ ┻━┻" 17 | } 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26, py27, py32, py33, py34, py35, py36, pypy, flake8 3 | 4 | [testenv] 5 | deps = coverage 6 | commands = 7 | coverage run --source={toxinidir}/chevron {toxinidir}/test_spec.py 8 | coverage report -m 9 | 10 | [testenv:py32] 11 | commands = python {toxinidir}/test_spec.py 12 | 13 | [testenv:flake8] 14 | deps = flake8 15 | commands = flake8 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Code coverage 4 | coverage.xml 5 | 6 | # Jython 7 | *.class 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .idea/ 35 | 36 | # VSCode 37 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Noah Morrison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import chevron.metadata 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | with open('README.md') as f: 12 | readme = f.read() 13 | 14 | setup(name='chevron', 15 | version=chevron.metadata.version, 16 | license='MIT', 17 | 18 | description='Mustache templating language renderer', 19 | long_description=readme, 20 | long_description_content_type='text/markdown', 21 | 22 | author='noah morrison', 23 | author_email='noah@morrison.ph', 24 | url='https://github.com/noahmorrison/chevron', 25 | 26 | packages=['chevron'], 27 | entry_points={ 28 | 'console_scripts': ['chevron=chevron:cli_main'] 29 | }, 30 | 31 | classifiers=[ 32 | 'Development Status :: 4 - Beta', 33 | 34 | 'License :: OSI Approved :: MIT License', 35 | 36 | 'Programming Language :: Python :: 2.6', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3.2', 39 | 'Programming Language :: Python :: 3.3', 40 | 'Programming Language :: Python :: 3.4', 41 | 'Programming Language :: Python :: 3.5', 42 | 'Programming Language :: Python :: 3.6', 43 | 'Topic :: Text Processing :: Markup' 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | 4 | python: 5 | - 2.7 6 | - 3.4 7 | - 3.5 8 | - 3.6 9 | - 3.7 10 | - 3.8 11 | - pypy 12 | 13 | matrix: 14 | include: 15 | - python: 2.6 16 | dist: trusty 17 | - python: 3.2 18 | dist: trusty 19 | - python: 3.3 20 | dist: trusty 21 | 22 | - python: 3.6 23 | env: 24 | - TOXENV=flake8 25 | - python: 3.6 26 | env: 27 | - PUBLISH="true" 28 | install: 29 | - pip install coveralls 30 | - pip install tox-travis 31 | script: 32 | - tox 33 | after_success: 34 | - coveralls 35 | 36 | script: 37 | - python ./test_spec.py 38 | 39 | deploy: 40 | - provider: pypi 41 | server: https://upload.pypi.org/legacy/ 42 | user: noahmorrison 43 | password: 44 | secure: GK+PXWWCczHMQAptgAA6umma0kP+fPsCktHzDnpb4Pa7RZBjxjS1mbRtwSfMm8gWcRYkqjO0la/t3TUP3qjfa+x5JmIwy2YTveBKmSjTxLAb9n7fCWZEGu9GWgGvfsmg2Hhi6NYUp9U7ggtoBXQ4AHgmnPyDCL2TQK32PKj5soM= 45 | distributions: sdist bdist_wheel 46 | skip_upload_docs: true 47 | on: 48 | tags: true 49 | repo: noahmorrison/chevron 50 | branch: master 51 | condition: $PUBLISH = "true" 52 | - provider: pypi 53 | server: https://test.pypi.org/legacy/ 54 | user: noahmorrison 55 | password: 56 | secure: GK+PXWWCczHMQAptgAA6umma0kP+fPsCktHzDnpb4Pa7RZBjxjS1mbRtwSfMm8gWcRYkqjO0la/t3TUP3qjfa+x5JmIwy2YTveBKmSjTxLAb9n7fCWZEGu9GWgGvfsmg2Hhi6NYUp9U7ggtoBXQ4AHgmnPyDCL2TQK32PKj5soM= 57 | distributions: sdist bdist_wheel 58 | skip_upload_docs: true 59 | on: 60 | tags: true 61 | repo: noahmorrison/chevron 62 | all_branches: true 63 | condition: $PUBLISH = "true" 64 | -------------------------------------------------------------------------------- /tests/test-partials-disabled.rendered: -------------------------------------------------------------------------------- 1 | 2 | variable test 3 | === 4 | test 5 | === 6 | test 7 | === 8 | 9 | comment test 10 | === 11 | === 12 | === 13 | 14 | html escape test (triple brackets) 15 | === 16 | < > & " 17 | === 18 | < > & " 19 | === 20 | 21 | html escape test (ampersand) 22 | === 23 | < > & " 24 | === 25 | < > & " 26 | === 27 | 28 | html escape test (normal) 29 | === 30 | < > & " 31 | === 32 | < > & " 33 | === 34 | 35 | section test (truthy) 36 | === 37 | true 38 | === 39 | true 40 | === 41 | 42 | section test (falsy) 43 | === 44 | === 45 | === 46 | 47 | section test (list) 48 | === 49 | number: 1 50 | name: one 51 | --- 52 | number: 2 53 | name: two 54 | --- 55 | number: 3 56 | name: three 57 | --- 58 | === 59 | number: 1 60 | name: one 61 | --- 62 | number: 2 63 | name: two 64 | --- 65 | number: 3 66 | name: three 67 | --- 68 | === 69 | 70 | section test (scope) 71 | === 72 | test 73 | new test 74 | === 75 | test 76 | new test 77 | === 78 | 79 | inverted section test (truthy) 80 | === 81 | === 82 | === 83 | 84 | inverted section test (falsy) 85 | === 86 | false 87 | === 88 | false 89 | === 90 | 91 | partial test 92 | === 93 | === 94 | this is a partial 95 | === 96 | 97 | delimiter test 98 | === 99 | test 100 | test 101 | === 102 | test 103 | test 104 | === 105 | 106 | unicode test (basic) 107 | === 108 | (╯°□°)╯︵ ┻━┻ 109 | === 110 | (╯°□°)╯︵ ┻━┻ 111 | === 112 | 113 | unicode test (variable) 114 | === 115 | (╯°□°)╯︵ ┻━┻ 116 | === 117 | (╯°□°)╯︵ ┻━┻ 118 | === 119 | 120 | unicode test (partial) 121 | === 122 | === 123 | (╯°□°)╯︵ ┻━┻ 124 | === 125 | 126 | unicode test (no-escape) 127 | === 128 | (╯°□°)╯︵ ┻━┻ 129 | === 130 | (╯°□°)╯︵ ┻━┻ 131 | === 132 | -------------------------------------------------------------------------------- /tests/test.rendered: -------------------------------------------------------------------------------- 1 | 2 | variable test 3 | === 4 | test 5 | === 6 | test 7 | === 8 | 9 | comment test 10 | === 11 | === 12 | === 13 | 14 | html escape test (triple brackets) 15 | === 16 | < > & " 17 | === 18 | < > & " 19 | === 20 | 21 | html escape test (ampersand) 22 | === 23 | < > & " 24 | === 25 | < > & " 26 | === 27 | 28 | html escape test (normal) 29 | === 30 | < > & " 31 | === 32 | < > & " 33 | === 34 | 35 | section test (truthy) 36 | === 37 | true 38 | === 39 | true 40 | === 41 | 42 | section test (falsy) 43 | === 44 | === 45 | === 46 | 47 | section test (list) 48 | === 49 | number: 1 50 | name: one 51 | --- 52 | number: 2 53 | name: two 54 | --- 55 | number: 3 56 | name: three 57 | --- 58 | === 59 | number: 1 60 | name: one 61 | --- 62 | number: 2 63 | name: two 64 | --- 65 | number: 3 66 | name: three 67 | --- 68 | === 69 | 70 | section test (scope) 71 | === 72 | test 73 | new test 74 | === 75 | test 76 | new test 77 | === 78 | 79 | inverted section test (truthy) 80 | === 81 | === 82 | === 83 | 84 | inverted section test (falsy) 85 | === 86 | false 87 | === 88 | false 89 | === 90 | 91 | partial test 92 | === 93 | this is a partial 94 | === 95 | this is a partial 96 | === 97 | 98 | delimiter test 99 | === 100 | test 101 | test 102 | === 103 | test 104 | test 105 | === 106 | 107 | unicode test (basic) 108 | === 109 | (╯°□°)╯︵ ┻━┻ 110 | === 111 | (╯°□°)╯︵ ┻━┻ 112 | === 113 | 114 | unicode test (variable) 115 | === 116 | (╯°□°)╯︵ ┻━┻ 117 | === 118 | (╯°□°)╯︵ ┻━┻ 119 | === 120 | 121 | unicode test (partial) 122 | === 123 | (╯°□°)╯︵ ┻━┻ 124 | === 125 | (╯°□°)╯︵ ┻━┻ 126 | === 127 | 128 | unicode test (no-escape) 129 | === 130 | (╯°□°)╯︵ ┻━┻ 131 | === 132 | (╯°□°)╯︵ ┻━┻ 133 | === 134 | -------------------------------------------------------------------------------- /tests/test.mustache: -------------------------------------------------------------------------------- 1 | {{! 2 | mustache comment! 3 | }} 4 | 5 | variable test 6 | === 7 | {{ test }} 8 | === 9 | test 10 | === 11 | 12 | comment test 13 | === 14 | {{! 15 | mustache comment 16 | }} 17 | === 18 | === 19 | 20 | html escape test (triple brackets) 21 | === 22 | {{{html_escaped}}} 23 | === 24 | < > & " 25 | === 26 | 27 | html escape test (ampersand) 28 | === 29 | {{& html_escaped}} 30 | === 31 | < > & " 32 | === 33 | 34 | html escape test (normal) 35 | === 36 | {{ html_escaped }} 37 | === 38 | < > & " 39 | === 40 | 41 | section test (truthy) 42 | === 43 | {{# true }} 44 | true 45 | {{/ true }} 46 | === 47 | true 48 | === 49 | 50 | section test (falsy) 51 | === 52 | {{# false }} 53 | ERROR 54 | {{/ false }} 55 | === 56 | === 57 | 58 | section test (list) 59 | === 60 | {{# list }} 61 | number: {{i}} 62 | name: {{v}} 63 | --- 64 | {{/ list }} 65 | === 66 | number: 1 67 | name: one 68 | --- 69 | number: 2 70 | name: two 71 | --- 72 | number: 3 73 | name: three 74 | --- 75 | === 76 | 77 | section test (scope) 78 | === 79 | {{ test }} 80 | {{# scope }} 81 | {{ test }} 82 | {{/ scope }} 83 | === 84 | test 85 | new test 86 | === 87 | 88 | inverted section test (truthy) 89 | === 90 | {{^ true }} 91 | ERROR 92 | {{/ true }} 93 | === 94 | === 95 | 96 | inverted section test (falsy) 97 | === 98 | {{^ false }} 99 | false 100 | {{/ false }} 101 | === 102 | false 103 | === 104 | 105 | partial test 106 | === 107 | {{> partial}} 108 | === 109 | this is a partial 110 | === 111 | 112 | delimiter test 113 | === 114 | {{=(( ))=}} 115 | (( test )) 116 | ((={{ }}=)) 117 | {{ test }} 118 | === 119 | test 120 | test 121 | === 122 | 123 | unicode test (basic) 124 | === 125 | (╯°□°)╯︵ ┻━┻ 126 | === 127 | (╯°□°)╯︵ ┻━┻ 128 | === 129 | 130 | unicode test (variable) 131 | === 132 | {{ unicode }} 133 | === 134 | (╯°□°)╯︵ ┻━┻ 135 | === 136 | 137 | unicode test (partial) 138 | === 139 | {{> unicode }} 140 | === 141 | (╯°□°)╯︵ ┻━┻ 142 | === 143 | 144 | unicode test (no-escape) 145 | === 146 | {{& unicode_html }} 147 | === 148 | (╯°□°)╯︵ ┻━┻ 149 | === 150 | -------------------------------------------------------------------------------- /benchmark.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # coding: utf-8 3 | 4 | from sys import argv 5 | from timeit import timeit 6 | 7 | import chevron 8 | 9 | 10 | def make_test(template=None, data=None, expected=None): 11 | def test(): 12 | result = chevron.render(template, data) 13 | if result != expected: 14 | error = 'Test failed:\n-- got --\n{}\n-- expected --\n{}' 15 | raise Exception(error.format(result, expected)) 16 | 17 | return test 18 | 19 | 20 | def main(times): 21 | args = { 22 | 'template': """\ 23 | {{# comments }} 24 |
25 | {{ user }} 26 | {{ body }} 27 | {{ vote }} 28 |
29 | {{/ comments }} 30 | """, 31 | 'data': { 32 | 'comments': [ 33 | {'user': 'tommy', 34 | 'body': 'If this gets to the front page I\'ll eat my hat!', 35 | 'vote': 625}, 36 | 37 | {'user': 'trololol', 38 | 'body': 'this', 39 | 'vote': -142}, 40 | 41 | {'user': 'mctom', 42 | 'body': 'I wish thinking of test phrases was easier', 43 | 'vote': 83}, 44 | 45 | {'user': 'the_thinker', 46 | 'body': 'Why is /u/trololol\'s post higher than ours?', 47 | 'vote': 36} 48 | ] 49 | }, 50 | 'expected': """\ 51 |
52 | tommy 53 | If this gets to the front page I'll eat my hat! 54 | 625 55 |
56 |
57 | trololol 58 | this 59 | -142 60 |
61 |
62 | mctom 63 | I wish thinking of test phrases was easier 64 | 83 65 |
66 |
67 | the_thinker 68 | Why is /u/trololol's post higher than ours? 69 | 36 70 |
71 | """ 72 | } 73 | 74 | test = make_test(**args) 75 | 76 | print(timeit(test, number=times)) 77 | 78 | 79 | if __name__ == '__main__': 80 | try: 81 | main(int(argv[1])) 82 | except IndexError: 83 | main(10000) 84 | -------------------------------------------------------------------------------- /chevron/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import io 4 | import sys 5 | 6 | try: 7 | from .renderer import render 8 | from .metadata import version 9 | except (ValueError, SystemError): # python 2 10 | from renderer import render 11 | from metadata import version 12 | 13 | 14 | def main(template, data=None, **kwargs): 15 | with io.open(template, 'r', encoding='utf-8') as template_file: 16 | yaml_loader = kwargs.pop('yaml_loader', None) or 'SafeLoader' 17 | 18 | if data is not None: 19 | with io.open(data, 'r', encoding='utf-8') as data_file: 20 | data = _load_data(data_file, yaml_loader) 21 | else: 22 | data = {} 23 | 24 | args = { 25 | 'template': template_file, 26 | 'data': data 27 | } 28 | 29 | args.update(kwargs) 30 | return render(**args) 31 | 32 | 33 | def _load_data(file, yaml_loader): 34 | try: 35 | import yaml 36 | loader = getattr(yaml, yaml_loader) # not tested 37 | return yaml.load(file, Loader=loader) # not tested 38 | except ImportError: 39 | import json 40 | return json.load(file) 41 | 42 | 43 | def cli_main(): 44 | """Render mustache templates using json files""" 45 | import argparse 46 | import os 47 | 48 | def is_file_or_pipe(arg): 49 | if not os.path.exists(arg) or os.path.isdir(arg): 50 | parser.error('The file {0} does not exist!'.format(arg)) 51 | else: 52 | return arg 53 | 54 | def is_dir(arg): 55 | if not os.path.isdir(arg): 56 | parser.error('The directory {0} does not exist!'.format(arg)) 57 | else: 58 | return arg 59 | 60 | parser = argparse.ArgumentParser(description=__doc__) 61 | 62 | parser.add_argument('-v', '--version', action='version', 63 | version=version) 64 | 65 | parser.add_argument('template', help='The mustache file', 66 | type=is_file_or_pipe) 67 | 68 | parser.add_argument('-d', '--data', dest='data', 69 | help='The json data file', 70 | type=is_file_or_pipe, default={}) 71 | 72 | parser.add_argument('-y', '--yaml-loader', dest='yaml_loader', 73 | help=argparse.SUPPRESS) 74 | 75 | parser.add_argument('-p', '--path', dest='partials_path', 76 | help='The directory where your partials reside', 77 | type=is_dir, default='.') 78 | 79 | parser.add_argument('-e', '--ext', dest='partials_ext', 80 | help='The extension for your mustache\ 81 | partials, \'mustache\' by default', 82 | default='mustache') 83 | 84 | parser.add_argument('-l', '--left-delimiter', dest='def_ldel', 85 | help='The default left delimiter, "{{" by default.', 86 | default='{{') 87 | 88 | parser.add_argument('-r', '--right-delimiter', dest='def_rdel', 89 | help='The default right delimiter, "}}" by default.', 90 | default='}}') 91 | 92 | parser.add_argument('-w', '--warn', dest='warn', 93 | help='Print a warning to stderr for each undefined template key encountered', 94 | action='store_true') 95 | 96 | 97 | args = vars(parser.parse_args()) 98 | 99 | try: 100 | sys.stdout.write(main(**args)) 101 | sys.stdout.flush() 102 | except SyntaxError as e: 103 | print('Chevron: syntax error') 104 | sys.exit(' ' + '\n '.join(e.args[0].split('\n'))) 105 | 106 | 107 | if __name__ == '__main__': 108 | cli_main() 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/chevron.svg)](https://badge.fury.io/py/chevron) 2 | [![Build Status](https://travis-ci.org/noahmorrison/chevron.svg?branch=master)](https://travis-ci.org/noahmorrison/chevron) 3 | [![Coverage Status](https://coveralls.io/repos/github/noahmorrison/chevron/badge.svg?branch=master)](https://coveralls.io/github/noahmorrison/chevron?branch=master) 4 | 5 | A python implementation of the [mustache templating language](http://mustache.github.io). 6 | 7 | Why chevron? 8 | ------------ 9 | 10 | I'm glad you asked! 11 | 12 | ### chevron is fast ### 13 | 14 | Chevron runs in less than half the time of [pystache](http://github.com/defunkt/pystache) (Which is not even up to date on the spec). 15 | And in about 70% the time of [Stache](https://github.com/hyperturtle/Stache) (A 'trimmed' version of mustache, also not spec compliant). 16 | 17 | ### chevron is pep8 ### 18 | 19 | The flake8 command is run by [travis](https://travis-ci.org/noahmorrison/chevron) to ensure consistency. 20 | 21 | ### chevron is spec compliant ### 22 | 23 | Chevron passes all the unittests provided by the [spec](https://github.com/mustache/spec) (in every version listed below). 24 | 25 | If you find a test that chevron does not pass, please [report it.](https://github.com/noahmorrison/chevron/issues/new) 26 | 27 | ### chevron is Python 2 and 3 compatible ### 28 | 29 | Python 2.6, 2.7, 3.2, 3.3, 3.4, 3.5, and 3.6 are all tested by travis. 30 | 31 | 32 | 33 | USAGE 34 | ----- 35 | 36 | Commandline usage: (if installed via pypi) 37 | ``` 38 | usage: chevron [-h] [-v] [-d DATA] [-p PARTIALS_PATH] [-e PARTIALS_EXT] 39 | [-l DEF_LDEL] [-r DEF_RDEL] 40 | template 41 | 42 | positional arguments: 43 | template The mustache file 44 | 45 | optional arguments: 46 | -h, --help show this help message and exit 47 | -v, --version show program's version number and exit 48 | -d DATA, --data DATA The json data file 49 | -p PARTIALS_PATH, --path PARTIALS_PATH 50 | The directory where your partials reside 51 | -e PARTIALS_EXT, --ext PARTIALS_EXT 52 | The extension for your mustache partials, 'mustache' 53 | by default 54 | -l DEF_LDEL, --left-delimiter DEF_LDEL 55 | The default left delimiter, "{{" by default. 56 | -r DEF_RDEL, --right-delimiter DEF_RDEL 57 | The default right delimiter, "}}" by default. 58 | ``` 59 | 60 | Python usage with strings 61 | ```python 62 | import chevron 63 | 64 | chevron.render('Hello, {{ mustache }}!', {'mustache': 'World'}) 65 | ``` 66 | 67 | Python usage with file 68 | ```python 69 | import chevron 70 | 71 | with open('file.mustache', 'r') as f: 72 | chevron.render(f, {'mustache': 'World'}) 73 | ``` 74 | 75 | Python usage with unpacking 76 | ```python 77 | import chevron 78 | 79 | args = { 80 | 'template': 'Hello, {{ mustache }}!', 81 | 82 | 'data': { 83 | 'mustache': 'World' 84 | } 85 | } 86 | 87 | chevron.render(**args) 88 | ``` 89 | 90 | chevron supports partials (via dictionaries) 91 | ```python 92 | import chevron 93 | 94 | args = { 95 | 'template': 'Hello, {{> thing }}!', 96 | 97 | 'partials_dict': { 98 | 'thing': 'World' 99 | } 100 | } 101 | 102 | chevron.render(**args) 103 | ``` 104 | 105 | chevron supports partials (via the filesystem) 106 | ```python 107 | import chevron 108 | 109 | args = { 110 | 'template': 'Hello, {{> thing }}!', 111 | 112 | # defaults to . 113 | 'partials_path': 'partials/', 114 | 115 | # defaults to mustache 116 | 'partials_ext': 'ms', 117 | } 118 | 119 | # ./partials/thing.ms will be read and rendered 120 | chevron.render(**args) 121 | ``` 122 | 123 | chevron supports lambdas 124 | ```python 125 | import chevron 126 | 127 | def first(text, render): 128 | # return only first occurance of items 129 | result = render(text) 130 | return [ x.strip() for x in result.split(" || ") if x.strip() ][0] 131 | 132 | def inject_x(text, render): 133 | # inject data into scope 134 | return render(text, {'x': 'data'}) 135 | 136 | args = { 137 | 'template': 'Hello, {{# first}} {{x}} || {{y}} || {{z}} {{/ first}}! {{# inject_x}} {{x}} {{/ inject_x}}', 138 | 139 | 'data': { 140 | 'y': 'foo', 141 | 'z': 'bar', 142 | 'first': first, 143 | 'inject_x': inject_x 144 | } 145 | } 146 | 147 | chevron.render(**args) 148 | ``` 149 | 150 | INSTALL 151 | ------- 152 | 153 | - with git 154 | ``` 155 | $ git clone https://github.com/noahmorrison/chevron.git 156 | ``` 157 | 158 | or using submodules 159 | ``` 160 | $ git submodules add https://github.com/noahmorrison/chevron.git 161 | ``` 162 | 163 | Also available on pypi! 164 | 165 | - with pip 166 | ``` 167 | $ pip install chevron 168 | ``` 169 | 170 | 171 | 172 | TODO 173 | --- 174 | 175 | * get popular 176 | * have people complain 177 | * fix those complaints 178 | -------------------------------------------------------------------------------- /chevron/tokenizer.py: -------------------------------------------------------------------------------- 1 | # Globals 2 | _CURRENT_LINE = 1 3 | _LAST_TAG_LINE = None 4 | 5 | 6 | class ChevronError(SyntaxError): 7 | pass 8 | 9 | # 10 | # Helper functions 11 | # 12 | 13 | 14 | def grab_literal(template, l_del): 15 | """Parse a literal from the template""" 16 | 17 | global _CURRENT_LINE 18 | 19 | try: 20 | # Look for the next tag and move the template to it 21 | literal, template = template.split(l_del, 1) 22 | _CURRENT_LINE += literal.count('\n') 23 | return (literal, template) 24 | 25 | # There are no more tags in the template? 26 | except ValueError: 27 | # Then the rest of the template is a literal 28 | return (template, '') 29 | 30 | 31 | def l_sa_check(template, literal, is_standalone): 32 | """Do a preliminary check to see if a tag could be a standalone""" 33 | 34 | # If there is a newline, or the previous tag was a standalone 35 | if literal.find('\n') != -1 or is_standalone: 36 | padding = literal.split('\n')[-1] 37 | 38 | # If all the characters since the last newline are spaces 39 | if padding.isspace() or padding == '': 40 | # Then the next tag could be a standalone 41 | return True 42 | else: 43 | # Otherwise it can't be 44 | return False 45 | 46 | 47 | def r_sa_check(template, tag_type, is_standalone): 48 | """Do a final checkto see if a tag could be a standalone""" 49 | 50 | # Check right side if we might be a standalone 51 | if is_standalone and tag_type not in ['variable', 'no escape']: 52 | on_newline = template.split('\n', 1) 53 | 54 | # If the stuff to the right of us are spaces we're a standalone 55 | if on_newline[0].isspace() or not on_newline[0]: 56 | return True 57 | else: 58 | return False 59 | 60 | # If we're a tag can't be a standalone 61 | else: 62 | return False 63 | 64 | 65 | def parse_tag(template, l_del, r_del): 66 | """Parse a tag from a template""" 67 | global _CURRENT_LINE 68 | global _LAST_TAG_LINE 69 | 70 | tag_types = { 71 | '!': 'comment', 72 | '#': 'section', 73 | '^': 'inverted section', 74 | '/': 'end', 75 | '>': 'partial', 76 | '=': 'set delimiter?', 77 | '{': 'no escape?', 78 | '&': 'no escape' 79 | } 80 | 81 | # Get the tag 82 | try: 83 | tag, template = template.split(r_del, 1) 84 | except ValueError: 85 | raise ChevronError('unclosed tag ' 86 | 'at line {0}'.format(_CURRENT_LINE)) 87 | 88 | # Find the type meaning of the first character 89 | tag_type = tag_types.get(tag[0], 'variable') 90 | 91 | # If the type is not a variable 92 | if tag_type != 'variable': 93 | # Then that first character is not needed 94 | tag = tag[1:] 95 | 96 | # If we might be a set delimiter tag 97 | if tag_type == 'set delimiter?': 98 | # Double check to make sure we are 99 | if tag.endswith('='): 100 | tag_type = 'set delimiter' 101 | # Remove the equal sign 102 | tag = tag[:-1] 103 | 104 | # Otherwise we should complain 105 | else: 106 | raise ChevronError('unclosed set delimiter tag\n' 107 | 'at line {0}'.format(_CURRENT_LINE)) 108 | 109 | # If we might be a no html escape tag 110 | elif tag_type == 'no escape?': 111 | # And we have a third curly brace 112 | # (And are using curly braces as delimiters) 113 | if l_del == '{{' and r_del == '}}' and template.startswith('}'): 114 | # Then we are a no html escape tag 115 | template = template[1:] 116 | tag_type = 'no escape' 117 | 118 | # Strip the whitespace off the key and return 119 | return ((tag_type, tag.strip()), template) 120 | 121 | 122 | # 123 | # The main tokenizing function 124 | # 125 | 126 | def tokenize(template, def_ldel='{{', def_rdel='}}'): 127 | """Tokenize a mustache template 128 | 129 | Tokenizes a mustache template in a generator fashion, 130 | using file-like objects. It also accepts a string containing 131 | the template. 132 | 133 | 134 | Arguments: 135 | 136 | template -- a file-like object, or a string of a mustache template 137 | 138 | def_ldel -- The default left delimiter 139 | ("{{" by default, as in spec compliant mustache) 140 | 141 | def_rdel -- The default right delimiter 142 | ("}}" by default, as in spec compliant mustache) 143 | 144 | 145 | Returns: 146 | 147 | A generator of mustache tags in the form of a tuple 148 | 149 | -- (tag_type, tag_key) 150 | 151 | Where tag_type is one of: 152 | * literal 153 | * section 154 | * inverted section 155 | * end 156 | * partial 157 | * no escape 158 | 159 | And tag_key is either the key or in the case of a literal tag, 160 | the literal itself. 161 | """ 162 | 163 | global _CURRENT_LINE, _LAST_TAG_LINE 164 | _CURRENT_LINE = 1 165 | _LAST_TAG_LINE = None 166 | # If the template is a file-like object then read it 167 | try: 168 | template = template.read() 169 | except AttributeError: 170 | pass 171 | 172 | is_standalone = True 173 | open_sections = [] 174 | l_del = def_ldel 175 | r_del = def_rdel 176 | 177 | while template: 178 | literal, template = grab_literal(template, l_del) 179 | 180 | # If the template is completed 181 | if not template: 182 | # Then yield the literal and leave 183 | yield ('literal', literal) 184 | break 185 | 186 | # Do the first check to see if we could be a standalone 187 | is_standalone = l_sa_check(template, literal, is_standalone) 188 | 189 | # Parse the tag 190 | tag, template = parse_tag(template, l_del, r_del) 191 | tag_type, tag_key = tag 192 | 193 | # Special tag logic 194 | 195 | # If we are a set delimiter tag 196 | if tag_type == 'set delimiter': 197 | # Then get and set the delimiters 198 | dels = tag_key.strip().split(' ') 199 | l_del, r_del = dels[0], dels[-1] 200 | 201 | # If we are a section tag 202 | elif tag_type in ['section', 'inverted section']: 203 | # Then open a new section 204 | open_sections.append(tag_key) 205 | _LAST_TAG_LINE = _CURRENT_LINE 206 | 207 | # If we are an end tag 208 | elif tag_type == 'end': 209 | # Then check to see if the last opened section 210 | # is the same as us 211 | try: 212 | last_section = open_sections.pop() 213 | except IndexError: 214 | raise ChevronError('Trying to close tag "{0}"\n' 215 | 'Looks like it was not opened.\n' 216 | 'line {1}' 217 | .format(tag_key, _CURRENT_LINE + 1)) 218 | if tag_key != last_section: 219 | # Otherwise we need to complain 220 | raise ChevronError('Trying to close tag "{0}"\n' 221 | 'last open tag is "{1}"\n' 222 | 'line {2}' 223 | .format(tag_key, last_section, 224 | _CURRENT_LINE + 1)) 225 | 226 | # Do the second check to see if we're a standalone 227 | is_standalone = r_sa_check(template, tag_type, is_standalone) 228 | 229 | # Which if we are 230 | if is_standalone: 231 | # Remove the stuff before the newline 232 | template = template.split('\n', 1)[-1] 233 | 234 | # Partials need to keep the spaces on their left 235 | if tag_type != 'partial': 236 | # But other tags don't 237 | literal = literal.rstrip(' ') 238 | 239 | # Start yielding 240 | # Ignore literals that are empty 241 | if literal != '': 242 | yield ('literal', literal) 243 | 244 | # Ignore comments and set delimiters 245 | if tag_type not in ['comment', 'set delimiter?']: 246 | yield (tag_type, tag_key) 247 | 248 | # If there are any open sections when we're done 249 | if open_sections: 250 | # Then we need to complain 251 | raise ChevronError('Unexpected EOF\n' 252 | 'the tag "{0}" was never closed\n' 253 | 'was opened at line {1}' 254 | .format(open_sections[-1], _LAST_TAG_LINE)) 255 | -------------------------------------------------------------------------------- /chevron/renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import io 4 | from os import linesep, path 5 | 6 | try: 7 | from collections.abc import Sequence, Iterator, Callable 8 | except ImportError: # python 2 9 | from collections import Sequence, Iterator, Callable 10 | try: 11 | from .tokenizer import tokenize 12 | except (ValueError, SystemError): # python 2 13 | from tokenizer import tokenize 14 | 15 | 16 | import sys 17 | if sys.version_info[0] == 3: 18 | python3 = True 19 | unicode_type = str 20 | string_type = str 21 | 22 | def unicode(x, y): 23 | return x 24 | 25 | else: # python 2 26 | python3 = False 27 | unicode_type = unicode 28 | string_type = basestring # noqa: F821 (This is defined in python2) 29 | 30 | 31 | # 32 | # Helper functions 33 | # 34 | 35 | def _html_escape(string): 36 | """HTML escape all of these " & < >""" 37 | 38 | html_codes = { 39 | '"': '"', 40 | '<': '<', 41 | '>': '>', 42 | } 43 | 44 | # & must be handled first 45 | string = string.replace('&', '&') 46 | for char in html_codes: 47 | string = string.replace(char, html_codes[char]) 48 | return string 49 | 50 | 51 | def _get_key(key, scopes, warn, keep, def_ldel, def_rdel): 52 | """Get a key from the current scope""" 53 | 54 | # If the key is a dot 55 | if key == '.': 56 | # Then just return the current scope 57 | return scopes[0] 58 | 59 | # Loop through the scopes 60 | for scope in scopes: 61 | try: 62 | # For every dot seperated key 63 | for child in key.split('.'): 64 | # Move into the scope 65 | try: 66 | # Try subscripting (Normal dictionaries) 67 | scope = scope[child] 68 | except (TypeError, AttributeError): 69 | try: 70 | scope = getattr(scope, child) 71 | except (TypeError, AttributeError): 72 | # Try as a list 73 | scope = scope[int(child)] 74 | 75 | # Return an empty string if falsy, with two exceptions 76 | # 0 should return 0, and False should return False 77 | if scope in (0, False): 78 | return scope 79 | 80 | try: 81 | # This allows for custom falsy data types 82 | # https://github.com/noahmorrison/chevron/issues/35 83 | if scope._CHEVRON_return_scope_when_falsy: 84 | return scope 85 | except AttributeError: 86 | return scope or '' 87 | except (AttributeError, KeyError, IndexError, ValueError): 88 | # We couldn't find the key in the current scope 89 | # We'll try again on the next pass 90 | pass 91 | 92 | # We couldn't find the key in any of the scopes 93 | 94 | if warn: 95 | sys.stderr.write("Could not find key '%s'%s" % (key, linesep)) 96 | 97 | if keep: 98 | return "%s %s %s" % (def_ldel, key, def_rdel) 99 | 100 | return '' 101 | 102 | 103 | def _get_partial(name, partials_dict, partials_path, partials_ext): 104 | """Load a partial""" 105 | try: 106 | # Maybe the partial is in the dictionary 107 | return partials_dict[name] 108 | except KeyError: 109 | # Don't try loading from the file system if the partials_path is None or empty 110 | if partials_path is None or partials_path == '': return '' 111 | 112 | # Nope... 113 | try: 114 | # Maybe it's in the file system 115 | path_ext = ('.' + partials_ext if partials_ext else '') 116 | partial_path = path.join(partials_path, name + path_ext) 117 | with io.open(partial_path, 'r', encoding='utf-8') as partial: 118 | return partial.read() 119 | 120 | except IOError: 121 | # Alright I give up on you 122 | return '' 123 | 124 | 125 | # 126 | # The main rendering function 127 | # 128 | g_token_cache = {} 129 | 130 | 131 | def render(template='', data={}, partials_path='.', partials_ext='mustache', 132 | partials_dict={}, padding='', def_ldel='{{', def_rdel='}}', 133 | scopes=None, warn=False, keep=False): 134 | """Render a mustache template. 135 | 136 | Renders a mustache template with a data scope and partial capability. 137 | Given the file structure... 138 | ╷ 139 | ├─╼ main.py 140 | ├─╼ main.ms 141 | └─┮ partials 142 | └── part.ms 143 | 144 | then main.py would make the following call: 145 | 146 | render(open('main.ms', 'r'), {...}, 'partials', 'ms') 147 | 148 | 149 | Arguments: 150 | 151 | template -- A file-like object or a string containing the template 152 | 153 | data -- A python dictionary with your data scope 154 | 155 | partials_path -- The path to where your partials are stored 156 | If set to None, then partials won't be loaded from the file system 157 | (defaults to '.') 158 | 159 | partials_ext -- The extension that you want the parser to look for 160 | (defaults to 'mustache') 161 | 162 | partials_dict -- A python dictionary which will be search for partials 163 | before the filesystem is. {'include': 'foo'} is the same 164 | as a file called include.mustache 165 | (defaults to {}) 166 | 167 | padding -- This is for padding partials, and shouldn't be used 168 | (but can be if you really want to) 169 | 170 | def_ldel -- The default left delimiter 171 | ("{{" by default, as in spec compliant mustache) 172 | 173 | def_rdel -- The default right delimiter 174 | ("}}" by default, as in spec compliant mustache) 175 | 176 | scopes -- The list of scopes that get_key will look through 177 | 178 | warn -- Issue a warning to stderr when a template substitution isn't found in the data 179 | 180 | keep -- Keep unreplaced tags when a template substitution isn't found in the data 181 | 182 | 183 | Returns: 184 | 185 | A string containing the rendered template. 186 | """ 187 | 188 | # If the template is a seqeuence but not derived from a string 189 | if isinstance(template, Sequence) and \ 190 | not isinstance(template, string_type): 191 | # Then we don't need to tokenize it 192 | # But it does need to be a generator 193 | tokens = (token for token in template) 194 | else: 195 | if template in g_token_cache: 196 | tokens = (token for token in g_token_cache[template]) 197 | else: 198 | # Otherwise make a generator 199 | tokens = tokenize(template, def_ldel, def_rdel) 200 | 201 | output = unicode('', 'utf-8') 202 | 203 | if scopes is None: 204 | scopes = [data] 205 | 206 | # Run through the tokens 207 | for tag, key in tokens: 208 | # Set the current scope 209 | current_scope = scopes[0] 210 | 211 | # If we're an end tag 212 | if tag == 'end': 213 | # Pop out of the latest scope 214 | del scopes[0] 215 | 216 | # If the current scope is falsy and not the only scope 217 | elif not current_scope and len(scopes) != 1: 218 | if tag in ['section', 'inverted section']: 219 | # Set the most recent scope to a falsy value 220 | # (I heard False is a good one) 221 | scopes.insert(0, False) 222 | 223 | # If we're a literal tag 224 | elif tag == 'literal': 225 | # Add padding to the key and add it to the output 226 | if not isinstance(key, unicode_type): # python 2 227 | key = unicode(key, 'utf-8') 228 | output += key.replace('\n', '\n' + padding) 229 | 230 | # If we're a variable tag 231 | elif tag == 'variable': 232 | # Add the html escaped key to the output 233 | thing = _get_key(key, scopes, warn=warn, keep=keep, def_ldel=def_ldel, def_rdel=def_rdel) 234 | if thing is True and key == '.': 235 | # if we've coerced into a boolean by accident 236 | # (inverted tags do this) 237 | # then get the un-coerced object (next in the stack) 238 | thing = scopes[1] 239 | if not isinstance(thing, unicode_type): 240 | thing = unicode(str(thing), 'utf-8') 241 | output += _html_escape(thing) 242 | 243 | # If we're a no html escape tag 244 | elif tag == 'no escape': 245 | # Just lookup the key and add it 246 | thing = _get_key(key, scopes, warn=warn, keep=keep, def_ldel=def_ldel, def_rdel=def_rdel) 247 | if not isinstance(thing, unicode_type): 248 | thing = unicode(str(thing), 'utf-8') 249 | output += thing 250 | 251 | # If we're a section tag 252 | elif tag == 'section': 253 | # Get the sections scope 254 | scope = _get_key(key, scopes, warn=warn, keep=keep, def_ldel=def_ldel, def_rdel=def_rdel) 255 | 256 | # If the scope is a callable (as described in 257 | # https://mustache.github.io/mustache.5.html) 258 | if isinstance(scope, Callable): 259 | 260 | # Generate template text from tags 261 | text = unicode('', 'utf-8') 262 | tags = [] 263 | for tag in tokens: 264 | if tag == ('end', key): 265 | break 266 | 267 | tags.append(tag) 268 | tag_type, tag_key = tag 269 | if tag_type == 'literal': 270 | text += tag_key 271 | elif tag_type == 'no escape': 272 | text += "%s& %s %s" % (def_ldel, tag_key, def_rdel) 273 | else: 274 | text += "%s%s %s%s" % (def_ldel, { 275 | 'commment': '!', 276 | 'section': '#', 277 | 'inverted section': '^', 278 | 'end': '/', 279 | 'partial': '>', 280 | 'set delimiter': '=', 281 | 'no escape': '&', 282 | 'variable': '' 283 | }[tag_type], tag_key, def_rdel) 284 | 285 | g_token_cache[text] = tags 286 | 287 | rend = scope(text, lambda template, data=None: render(template, 288 | data={}, 289 | partials_path=partials_path, 290 | partials_ext=partials_ext, 291 | partials_dict=partials_dict, 292 | padding=padding, 293 | def_ldel=def_ldel, def_rdel=def_rdel, 294 | scopes=data and [data]+scopes or scopes, 295 | warn=warn, keep=keep)) 296 | 297 | if python3: 298 | output += rend 299 | else: # python 2 300 | output += rend.decode('utf-8') 301 | 302 | # If the scope is a sequence, an iterator or generator but not 303 | # derived from a string 304 | elif isinstance(scope, (Sequence, Iterator)) and \ 305 | not isinstance(scope, string_type): 306 | # Then we need to do some looping 307 | 308 | # Gather up all the tags inside the section 309 | # (And don't be tricked by nested end tags with the same key) 310 | # TODO: This feels like it still has edge cases, no? 311 | tags = [] 312 | tags_with_same_key = 0 313 | for tag in tokens: 314 | if tag == ('section', key): 315 | tags_with_same_key += 1 316 | if tag == ('end', key): 317 | tags_with_same_key -= 1 318 | if tags_with_same_key < 0: 319 | break 320 | tags.append(tag) 321 | 322 | # For every item in the scope 323 | for thing in scope: 324 | # Append it as the most recent scope and render 325 | new_scope = [thing] + scopes 326 | rend = render(template=tags, scopes=new_scope, 327 | padding=padding, 328 | partials_path=partials_path, 329 | partials_ext=partials_ext, 330 | partials_dict=partials_dict, 331 | def_ldel=def_ldel, def_rdel=def_rdel, 332 | warn=warn, keep=keep) 333 | 334 | if python3: 335 | output += rend 336 | else: # python 2 337 | output += rend.decode('utf-8') 338 | 339 | else: 340 | # Otherwise we're just a scope section 341 | scopes.insert(0, scope) 342 | 343 | # If we're an inverted section 344 | elif tag == 'inverted section': 345 | # Add the flipped scope to the scopes 346 | scope = _get_key(key, scopes, warn=warn, keep=keep, def_ldel=def_ldel, def_rdel=def_rdel) 347 | scopes.insert(0, not scope) 348 | 349 | # If we're a partial 350 | elif tag == 'partial': 351 | # Load the partial 352 | partial = _get_partial(key, partials_dict, 353 | partials_path, partials_ext) 354 | 355 | # Find what to pad the partial with 356 | left = output.rpartition('\n')[2] 357 | part_padding = padding 358 | if left.isspace(): 359 | part_padding += left 360 | 361 | # Render the partial 362 | part_out = render(template=partial, partials_path=partials_path, 363 | partials_ext=partials_ext, 364 | partials_dict=partials_dict, 365 | def_ldel=def_ldel, def_rdel=def_rdel, 366 | padding=part_padding, scopes=scopes, 367 | warn=warn, keep=keep) 368 | 369 | # If the partial was indented 370 | if left.isspace(): 371 | # then remove the spaces from the end 372 | part_out = part_out.rstrip(' \t') 373 | 374 | # Add the partials output to the ouput 375 | if python3: 376 | output += part_out 377 | else: # python 2 378 | output += part_out.decode('utf-8') 379 | 380 | if python3: 381 | return output 382 | else: # python 2 383 | return output.encode('utf-8') 384 | -------------------------------------------------------------------------------- /test_spec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import collections 5 | import unittest 6 | import os 7 | import json 8 | import io 9 | 10 | import chevron 11 | 12 | import sys 13 | if sys.version_info[0] == 3: 14 | python3 = True 15 | else: # python 2 16 | python3 = False 17 | 18 | 19 | SPECS_PATH = os.path.join('spec', 'specs') 20 | if os.path.exists(SPECS_PATH): 21 | SPECS = [path for path in os.listdir(SPECS_PATH) if path.endswith('.json')] 22 | else: 23 | SPECS = [] 24 | 25 | STACHE = chevron.render 26 | 27 | 28 | def _test_case_from_path(json_path): 29 | json_path = '%s.json' % json_path 30 | 31 | class MustacheTestCase(unittest.TestCase): 32 | """A simple yaml based test case""" 33 | 34 | def _test_from_object(obj): 35 | """Generate a unit test from a test object""" 36 | 37 | def test_case(self): 38 | result = STACHE(obj['template'], obj['data'], 39 | partials_dict=obj.get('partials', {})) 40 | 41 | self.assertEqual(result, obj['expected']) 42 | 43 | test_case.__doc__ = 'suite: {0} desc: {1}'.format(spec, 44 | obj['desc']) 45 | return test_case 46 | 47 | with io.open(json_path, 'r', encoding='utf-8') as f: 48 | yaml = json.load(f) 49 | 50 | # Generates a unit test for each test object 51 | for i, test in enumerate(yaml['tests']): 52 | vars()['test_' + str(i)] = _test_from_object(test) 53 | 54 | # Return the built class 55 | return MustacheTestCase 56 | 57 | 58 | # Create TestCase for each json file 59 | for spec in SPECS: 60 | # Ignore optional tests 61 | if spec[0] != '~': 62 | spec = spec.split('.')[0] 63 | globals()[spec] = _test_case_from_path(os.path.join(SPECS_PATH, spec)) 64 | 65 | 66 | class ExpandedCoverage(unittest.TestCase): 67 | 68 | def test_unclosed_sections(self): 69 | test1 = { 70 | 'template': '{{# section }} oops {{/ wrong_section }}' 71 | } 72 | 73 | test2 = { 74 | 'template': '{{# section }} end of file' 75 | } 76 | 77 | self.assertRaises(chevron.ChevronError, chevron.render, **test1) 78 | self.assertRaises(chevron.ChevronError, chevron.render, **test2) 79 | # check SyntaxError still catches ChevronError: 80 | self.assertRaises(SyntaxError, chevron.render, **test1) 81 | 82 | def test_bad_set_delimiter_tag(self): 83 | args = { 84 | 'template': '{{= bad!}}' 85 | } 86 | 87 | self.assertRaises(SyntaxError, chevron.render, **args) 88 | 89 | def test_unicode_basic(self): 90 | args = { 91 | 'template': '(╯°□°)╯︵ ┻━┻' 92 | } 93 | 94 | result = chevron.render(**args) 95 | expected = '(╯°□°)╯︵ ┻━┻' 96 | 97 | self.assertEqual(result, expected) 98 | 99 | def test_unicode_variable(self): 100 | args = { 101 | 'template': '{{ table_flip }}', 102 | 'data': {'table_flip': '(╯°□°)╯︵ ┻━┻'} 103 | } 104 | 105 | result = chevron.render(**args) 106 | expected = '(╯°□°)╯︵ ┻━┻' 107 | 108 | self.assertEqual(result, expected) 109 | 110 | def test_unicode_partial(self): 111 | args = { 112 | 'template': '{{> table_flip }}', 113 | 'partials_dict': {'table_flip': '(╯°□°)╯︵ ┻━┻'} 114 | } 115 | 116 | result = chevron.render(**args) 117 | expected = '(╯°□°)╯︵ ┻━┻' 118 | 119 | self.assertEqual(result, expected) 120 | 121 | def test_missing_key_partial(self): 122 | args = { 123 | 'template': 'before, {{> with_missing_key }}, after', 124 | 'partials_dict': { 125 | 'with_missing_key': '{{#missing_key}}bloop{{/missing_key}}', 126 | }, 127 | } 128 | 129 | result = chevron.render(**args) 130 | expected = 'before, , after' 131 | 132 | self.assertEqual(result, expected) 133 | 134 | def test_listed_data(self): 135 | args = { 136 | 'template': '{{# . }}({{ . }}){{/ . }}', 137 | 'data': [1, 2, 3, 4, 5] 138 | } 139 | 140 | result = chevron.render(**args) 141 | expected = '(1)(2)(3)(4)(5)' 142 | 143 | self.assertEqual(result, expected) 144 | 145 | def test_main(self): 146 | result = chevron.main('tests/test.mustache', 'tests/data.json', 147 | partials_path='tests') 148 | 149 | with io.open('tests/test.rendered', 'r', encoding='utf-8') as f: 150 | expected = f.read() 151 | if not python3: 152 | expected = expected.encode('utf-8') 153 | 154 | self.assertEqual(result, expected) 155 | 156 | def test_recursion(self): 157 | args = { 158 | 'template': '{{# 1.2 }}{{# data }}{{.}}{{/ data }}{{/ 1.2 }}', 159 | 'data': {'1': {'2': [{'data': ["1", "2", "3"]}]}} 160 | } 161 | 162 | result = chevron.render(**args) 163 | expected = '123' 164 | 165 | self.assertEqual(result, expected) 166 | 167 | def test_unicode_inside_list(self): 168 | args = { 169 | 'template': '{{#list}}{{.}}{{/list}}', 170 | 'data': {'list': ['☠']} 171 | } 172 | 173 | result = chevron.render(**args) 174 | expected = '☠' 175 | 176 | self.assertEqual(result, expected) 177 | 178 | def test_falsy(self): 179 | args = { 180 | 'template': '{{null}}{{false}}{{list}}{{dict}}{{zero}}', 181 | 'data': {'null': None, 182 | 'false': False, 183 | 'list': [], 184 | 'dict': {}, 185 | 'zero': 0 186 | } 187 | } 188 | 189 | result = chevron.render(**args) 190 | expected = 'False0' 191 | 192 | self.assertEqual(result, expected) 193 | 194 | def test_complex(self): 195 | class Complex: 196 | def __init__(self): 197 | self.attr = 42 198 | 199 | args = { 200 | 'template': '{{comp.attr}} {{int.attr}}', 201 | 'data': {'comp': Complex(), 202 | 'int': 1 203 | } 204 | } 205 | 206 | result = chevron.render(**args) 207 | expected = '42 ' 208 | 209 | self.assertEqual(result, expected) 210 | 211 | # https://github.com/noahmorrison/chevron/issues/17 212 | def test_inverted_coercion(self): 213 | args = { 214 | 'template': '{{#object}}{{^child}}{{.}}{{/child}}{{/object}}', 215 | 'data': {'object': [ 216 | 'foo', 'bar', {'child': True}, 'baz' 217 | ]} 218 | } 219 | 220 | result = chevron.render(**args) 221 | expected = 'foobarbaz' 222 | 223 | self.assertEqual(result, expected) 224 | 225 | def test_closing_tag_only(self): 226 | args = { 227 | 'template': '{{ foo } bar', 228 | 'data': {'foo': 'xx'} 229 | } 230 | 231 | self.assertRaises(chevron.ChevronError, chevron.render, **args) 232 | 233 | def test_current_line_rest(self): 234 | args = { 235 | 'template': 'first line\nsecond line\n {{ foo } bar', 236 | 'data': {'foo': 'xx'} 237 | } 238 | 239 | # self.assertRaisesRegex does not exist in python2.6 240 | for _ in range(10): 241 | try: 242 | chevron.render(**args) 243 | except chevron.ChevronError as error: 244 | self.assertEqual(error.msg, 'unclosed tag at line 3') 245 | 246 | def test_no_opening_tag(self): 247 | args = { 248 | 'template': 'oops, no opening tag {{/ closing_tag }}', 249 | 'data': {'foo': 'xx'} 250 | } 251 | 252 | try: 253 | chevron.render(**args) 254 | except chevron.ChevronError as error: 255 | self.assertEqual(error.msg, 'Trying to close tag "closing_tag"\n' 256 | 'Looks like it was not opened.\n' 257 | 'line 2') 258 | 259 | # https://github.com/noahmorrison/chevron/issues/17 260 | def test_callable_1(self): 261 | args_passed = {} 262 | 263 | def first(content, render): 264 | args_passed['content'] = content 265 | args_passed['render'] = render 266 | 267 | return "not implemented" 268 | 269 | args = { 270 | 'template': '{{{postcode}}} {{#first}} {{{city}}} || {{{town}}} ' 271 | '|| {{{village}}} || {{{state}}} {{/first}}', 272 | 'data': { 273 | "postcode": "1234", 274 | "city": "Mustache City", 275 | "state": "Nowhere", 276 | "first": first, 277 | } 278 | 279 | } 280 | 281 | result = chevron.render(**args) 282 | expected = '1234 not implemented' 283 | template_content = " {{& city }} || {{& town }} || {{& village }} "\ 284 | "|| {{& state }} " 285 | 286 | self.assertEqual(result, expected) 287 | self.assertEqual(args_passed['content'], template_content) 288 | 289 | def test_callable_2(self): 290 | 291 | def first(content, render): 292 | result = render(content) 293 | result = [x.strip() for x in result.split(" || ") if x.strip()] 294 | return result[0] 295 | 296 | args = { 297 | 'template': '{{{postcode}}} {{#first}} {{{city}}} || {{{town}}} ' 298 | '|| {{{village}}} || {{{state}}} {{/first}}', 299 | 'data': { 300 | "postcode": "1234", 301 | "town": "Mustache Town", 302 | "state": "Nowhere", 303 | "first": first, 304 | } 305 | } 306 | 307 | result = chevron.render(**args) 308 | expected = '1234 Mustache Town' 309 | 310 | self.assertEqual(result, expected) 311 | 312 | def test_callable_3(self): 313 | '''Test generating some data within the function 314 | ''' 315 | 316 | def first(content, render): 317 | result = render(content, {'city': "Injected City"}) 318 | result = [x.strip() for x in result.split(" || ") if x.strip()] 319 | return result[0] 320 | 321 | args = { 322 | 'template': '{{{postcode}}} {{#first}} {{{city}}} || {{{town}}} ' 323 | '|| {{{village}}} || {{{state}}} {{/first}}', 324 | 'data': { 325 | "postcode": "1234", 326 | "town": "Mustache Town", 327 | "state": "Nowhere", 328 | "first": first, 329 | } 330 | } 331 | 332 | result = chevron.render(**args) 333 | expected = '1234 Injected City' 334 | 335 | self.assertEqual(result, expected) 336 | 337 | def test_callable_4(self): 338 | '''Test render of partial inside lambda 339 | ''' 340 | 341 | def function(content, render): 342 | return render(content) 343 | 344 | args = { 345 | 'template': '{{#function}}{{>partial}}{{!comment}}{{/function}}', 346 | 'partials_dict': { 347 | 'partial': 'partial content', 348 | }, 349 | 'data': { 350 | 'function': function, 351 | } 352 | } 353 | 354 | result = chevron.render(**args) 355 | expected = 'partial content' 356 | 357 | self.assertEqual(result, expected) 358 | 359 | # https://github.com/noahmorrison/chevron/issues/35 360 | def test_custom_falsy(self): 361 | class CustomData(dict): 362 | class LowercaseBool: 363 | _CHEVRON_return_scope_when_falsy = True 364 | 365 | def __init__(self, value): 366 | self.value = value 367 | 368 | def __bool__(self): 369 | return self.value 370 | __nonzero__ = __bool__ 371 | 372 | def __str__(self): 373 | if self.value: 374 | return 'true' 375 | return 'false' 376 | 377 | def __getitem__(self, key): 378 | item = dict.__getitem__(self, key) 379 | if isinstance(item, dict): 380 | return CustomData(item) 381 | if isinstance(item, bool): 382 | return self.LowercaseBool(item) 383 | return item 384 | 385 | args = { 386 | 'data': CustomData({ 387 | 'truthy': True, 388 | 'falsy': False, 389 | }), 390 | 'template': '{{ truthy }} {{ falsy }}', 391 | } 392 | 393 | result = chevron.render(**args) 394 | expected = 'true false' 395 | 396 | self.assertEqual(result, expected) 397 | 398 | # https://github.com/noahmorrison/chevron/issues/39 399 | def test_nest_loops_with_same_key(self): 400 | args = { 401 | 'template': 'A{{#x}}B{{#x}}{{.}}{{/x}}C{{/x}}D', 402 | 'data': {'x': ['z', 'x']} 403 | } 404 | 405 | result = chevron.render(**args) 406 | expected = 'ABzxCBzxCD' 407 | 408 | self.assertEqual(result, expected) 409 | 410 | # https://github.com/noahmorrison/chevron/issues/49 411 | def test_partial_indentation(self): 412 | args = { 413 | 'template': '\t{{> count }}', 414 | 'partials_dict': { 415 | 'count': '\tone\n\ttwo' 416 | } 417 | } 418 | 419 | result = chevron.render(**args) 420 | expected = '\t\tone\n\t\ttwo' 421 | 422 | self.assertEqual(result, expected) 423 | 424 | # https://github.com/noahmorrison/chevron/issues/52 425 | def test_indexed(self): 426 | args = { 427 | 'template': 'count {{count.0}}, {{count.1}}, ' 428 | '{{count.100}}, {{nope.0}}', 429 | 'data': { 430 | "count": [5, 4, 3, 2, 1], 431 | } 432 | } 433 | 434 | result = chevron.render(**args) 435 | expected = 'count 5, 4, , ' 436 | 437 | self.assertEqual(result, expected) 438 | 439 | def test_iterator_scope_indentation(self): 440 | args = { 441 | 'data': { 442 | 'thing': ['foo', 'bar', 'baz'], 443 | }, 444 | 'template': '{{> count }}', 445 | 'partials_dict': { 446 | 'count': ' {{> iter_scope }}', 447 | 'iter_scope': 'foobar\n{{#thing}}\n {{.}}\n{{/thing}}' 448 | } 449 | } 450 | 451 | result = chevron.render(**args) 452 | expected = ' foobar\n foo\n bar\n baz\n' 453 | 454 | self.assertEqual(result, expected) 455 | 456 | # https://github.com/noahmorrison/chevron/pull/73 457 | def test_namedtuple_data(self): 458 | NT = collections.namedtuple('NT', ['foo', 'bar']) 459 | args = { 460 | 'template': '{{foo}} {{bar}}', 461 | 'data': NT('hello', 'world') 462 | } 463 | 464 | result = chevron.render(**args) 465 | expected = 'hello world' 466 | 467 | self.assertEqual(result, expected) 468 | 469 | def test_get_key_not_in_dunder_dict_returns_attribute(self): 470 | class C: 471 | foo = "bar" 472 | 473 | instance = C() 474 | self.assertTrue("foo" not in instance.__dict__) 475 | 476 | args = { 477 | 'template': '{{foo}}', 478 | 'data': instance 479 | } 480 | result = chevron.render(**args) 481 | expected = 'bar' 482 | 483 | self.assertEqual(result, expected) 484 | 485 | def test_disabled_partials(self): 486 | os.chdir('tests') 487 | resultNone = chevron.main('test.mustache', 'data.json', 488 | partials_path=None) 489 | 490 | resultEmpty = chevron.main('test.mustache', 'data.json', 491 | partials_path='') 492 | 493 | with io.open('test-partials-disabled.rendered', 'r', encoding='utf-8') as f: 494 | expected = f.read() 495 | if not python3: 496 | expected = expected.encode('utf-8') 497 | 498 | self.assertEqual(resultNone, expected) 499 | self.assertEqual(resultEmpty, expected) 500 | os.chdir('..') 501 | 502 | # https://github.com/noahmorrison/chevron/pull/94 503 | def test_keep(self): 504 | args = { 505 | 'template': '{{ first }} {{ second }} {{ third }}', 506 | 'data': { 507 | "first": "1st", 508 | "third": "3rd", 509 | }, 510 | } 511 | 512 | result = chevron.render(**args) 513 | expected = '1st 3rd' 514 | self.assertEqual(result, expected) 515 | 516 | args['keep'] = True 517 | 518 | result = chevron.render(**args) 519 | expected = '1st {{ second }} 3rd' 520 | self.assertEqual(result, expected) 521 | 522 | args['template'] = '{{first}} {{second}} {{third}}' 523 | result = chevron.render(**args) 524 | expected = '1st {{ second }} 3rd' 525 | self.assertEqual(result, expected) 526 | 527 | args['template'] = '{{ first }} {{ second }} {{ third }}' 528 | result = chevron.render(**args) 529 | expected = '1st {{ second }} 3rd' 530 | self.assertEqual(result, expected) 531 | 532 | # https://github.com/noahmorrison/chevron/pull/94 533 | def test_keep_from_partials(self): 534 | args = { 535 | 'template': '{{ first }} {{> with_missing_key }} {{ third }}', 536 | 'data': { 537 | "first": "1st", 538 | "third": "3rd", 539 | }, 540 | 'partials_dict': { 541 | 'with_missing_key': '{{missing_key}}', 542 | }, 543 | } 544 | 545 | result = chevron.render(**args) 546 | expected = '1st 3rd' 547 | self.assertEqual(result, expected) 548 | 549 | args['keep'] = True 550 | 551 | result = chevron.render(**args) 552 | expected = '1st {{ missing_key }} 3rd' 553 | self.assertEqual(result, expected) 554 | 555 | 556 | # Run unit tests from command line 557 | if __name__ == "__main__": 558 | unittest.main() 559 | --------------------------------------------------------------------------------