├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── REFERENCE.md ├── hamlpy ├── __init__.py ├── apps.py ├── compiler.py ├── hamlpy_watcher.py ├── jinja.py ├── parser │ ├── __init__.py │ ├── attributes.py │ ├── core.py │ ├── elements.py │ ├── filters.py │ ├── nodes.py │ └── utils.py ├── template │ ├── __init__.py │ ├── loaders.py │ ├── templatize.py │ └── utils.py ├── test │ ├── __init__.py │ ├── settings.py │ ├── templates │ │ ├── allIfTypesTest.hamlpy │ │ ├── allIfTypesTest.html │ │ ├── classIdMixtures.hamlpy │ │ ├── classIdMixtures.html │ │ ├── djangoBase.hamlpy │ │ ├── djangoBase.html │ │ ├── djangoCombo.hamlpy │ │ ├── djangoCombo.html │ │ ├── filterMultilineIgnore.hamlpy │ │ ├── filterMultilineIgnore.html │ │ ├── filters.hamlpy │ │ ├── filters.html │ │ ├── filtersMarkdown.hamlpy │ │ ├── filtersMarkdown.html │ │ ├── filtersPygments.hamlpy │ │ ├── filtersPygments.html │ │ ├── hamlComments.hamlpy │ │ ├── hamlComments.html │ │ ├── implicitDivs.hamlpy │ │ ├── implicitDivs.html │ │ ├── multiLineDict.hamlpy │ │ ├── multiLineDict.html │ │ ├── nestedComments.hamlpy │ │ ├── nestedComments.html │ │ ├── nestedDjangoTags.hamlpy │ │ ├── nestedDjangoTags.html │ │ ├── nestedIfElseBlocks.hamlpy │ │ ├── nestedIfElseBlocks.html │ │ ├── nukeInnerWhiteSpace.hamlpy │ │ ├── nukeInnerWhiteSpace.html │ │ ├── nukeOuterWhiteSpace.hamlpy │ │ ├── nukeOuterWhiteSpace.html │ │ ├── selfClosingDjango.hamlpy │ │ ├── selfClosingDjango.html │ │ ├── selfClosingTags.hamlpy │ │ ├── selfClosingTags.html │ │ ├── simple.hamlpy │ │ ├── simple.html │ │ ├── whitespacePreservation.hamlpy │ │ └── whitespacePreservation.html │ ├── test_attributes.py │ ├── test_compiler.py │ ├── test_elements.py │ ├── test_jinja.py │ ├── test_loader.py │ ├── test_nodes.py │ ├── test_parser.py │ ├── test_templates.py │ ├── test_templatize.py │ ├── test_views.py │ └── test_watcher.py └── views │ ├── __init__.py │ └── generic │ └── __init__.py ├── poetry.lock ├── pyproject.toml └── setup.cfg /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test 6 | strategy: 7 | matrix: 8 | python-version: ['3.9.x', '3.10.x', '3.11.x'] 9 | django-version: ['4.0.10', '4.1.9', '4.2.1'] 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Install Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | - name: Initialize environment 21 | run: | 22 | python -m pip install -U pip poetry 23 | poetry install 24 | poetry run pip install -q django==${{ matrix.django-version }} 25 | 26 | - name: Run pre-test checks 27 | run: | 28 | poetry run black --check hamlpy 29 | poetry run ruff hamlpy 30 | poetry run isort hamlpy 31 | 32 | - name: Run tests 33 | run: poetry run py.test --cov-report=xml --cov=hamlpy hamlpy 34 | 35 | - name: Upload coverage 36 | if: success() 37 | uses: codecov/codecov-action@v3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | fail_ci_if_error: true 41 | 42 | release: 43 | name: Release 44 | needs: [test] 45 | if: startsWith(github.ref, 'refs/tags/') 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v3 50 | 51 | - name: Install Python 52 | uses: actions/setup-python@v4 53 | with: 54 | python-version: '3.9.x' 55 | 56 | - name: Publish release 57 | run: | 58 | python -m pip install -U pip poetry 59 | poetry build 60 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 61 | poetry publish 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .cache 3 | .coverage 4 | .project 5 | .pydevproject 6 | .DS_Store 7 | .vscode 8 | .idea/ 9 | .tox/ 10 | .watcher_test/ 11 | .venv 12 | coverage.xml 13 | build/ 14 | htmlcov/ 15 | env/ 16 | dist/ 17 | django_hamlpy.egg-info/ 18 | fabric 19 | fabfile.* 20 | deploy 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.7.0 (2023-05-12) 2 | ------------------------- 3 | * Add smart_quotes option to replace conflicting quotes in attribute values with ' or " 4 | 5 | v1.6.0 (2023-05-11) 6 | ------------------------- 7 | * Add support for named end blocks 8 | * Fix using double quotes as attribute wrapper 9 | * Update required python version to 3.9 10 | 11 | v1.5.0 (2023-03-03) 12 | ------------------------- 13 | * Update .gitignore 14 | * Merge pull request #95 from nyaruka/fix-app-config 15 | * Test in python 3.11 as well 16 | * Run black 17 | * Update pygments to 2.14.0 18 | * Add apps submodule 19 | * Update deps for code styles 20 | * Update README.md 21 | 22 | v1.4.4 23 | ---------- 24 | * Loosen regex dependency 25 | 26 | v1.4.3 27 | ---------- 28 | * Fix Django requirement to properly support 3.x 29 | 30 | v1.4.2 31 | ---------- 32 | * Switch to new release system 33 | 34 | 1.4.1 (2020-12-08) 35 | ================ 36 | 37 | * Allow colons inside class names 38 | 39 | 1.3 (2020-09-21) 40 | ================ 41 | 42 | * Add support for markdown extensions (https://github.com/nyaruka/django-hamlpy/pull/78) 43 | * Drop support for Django 1.x, test on 2.x and 3.x 44 | 45 | 1.1.1 (2017-05-29) 46 | =================== 47 | 48 | * Fix patching makemessages on Django 1.11 49 | 50 | 1.1 (2017-03-20) 51 | =================== 52 | 53 | * Add support for more doc types (default is HTML5) (https://github.com/nyaruka/django-hamlpy/pull/63) 54 | * Add format (html4/html5/xhtml) and escape_attrs (True/False) compiler options 55 | * Don't use empty tag syntax or CDATA sections (can be overridden) in HTML5 format 56 | * Fix patching of templatize for makemessages (https://github.com/nyaruka/django-hamlpy/pull/65) 57 | * Add support for Django 1.11 betas (https://github.com/nyaruka/django-hamlpy/pull/62) 58 | * Fix use of plural tag within blocktrans tag (https://github.com/nyaruka/django-hamlpy/pull/57) 59 | * Add less, sass and escaped filters (https://github.com/nyaruka/django-hamlpy/pull/55) 60 | 61 | 1.0.1 (2017-01-26) 62 | =================== 63 | 64 | * Add :preserve filter (https://github.com/nyaruka/django-hamlpy/pull/49) 65 | * Fix filter lookup to ignore trailing whitespace 66 | 67 | 1.0 (2016-12-14) 68 | =================== 69 | 70 | This is the first major release and there are some potentially breaking changes noted below. 71 | 72 | * Refactor of parsing code giving ~40% performance improvement 73 | * Added support for HTML style attribute dictionaries, e.g. `%span(foo="bar")` 74 | * Improved error reporting from parser to help find problems in your templates 75 | * Fixed attribute values not being able to include braces (https://github.com/nyaruka/django-hamlpy/issues/39) 76 | * Fixed attribute values which are Haml not being able to have blank lines (https://github.com/nyaruka/django-hamlpy/issues/41) 77 | * Fixed sequential `with` tags ended up nested (https://github.com/nyaruka/django-hamlpy/issues/23) 78 | * Fixed templatize patching for Django 1.9+ 79 | * Changed support for `={..}` style expressions to be disabled by default (https://github.com/nyaruka/django-hamlpy/issues/16) 80 | * Removed support for `=#` comment syntax 81 | * Project now maintained by Nyaruka (https://github.com/nyaruka) 82 | 83 | Breaking Changes 84 | ---------------- 85 | 86 | * Support for `={...}` variable substitutions is deprecated and disabled by default, but can be enabled by setting 87 | `HAMLPY_DJANGO_INLINE_STYLE` to `True` if you are using the template loaders, or specifying --django-inline if you are 88 | using the watcher script. The preferred syntax for variable substitutions is `#{...}` as this is actual Haml and is 89 | less likely conflict with other uses of the `=` character. 90 | * The `=# ...` comment syntax is no longer supported. This is not Haml and was never documented anywhere. You should use 91 | the `-# ...` syntax instead. 92 | * Any line beginning with a colon is interpreted as a filter, so if this is not the case, you should escape the colon, 93 | e.g. `\:not-a-filter ` 94 | 95 | 0.86.1 (2016-11-15) 96 | =================== 97 | 98 | * Fixed some incorrect relative imports #21 by @Kangaroux 99 | 100 | 0.86 (2016-11-11) 101 | ================= 102 | 103 | * Add call and macro tags to the self-closing dict by @andreif 104 | * Remove django 1.1 support by @rowanseymour 105 | * Switch to a real parser instead of eval by @rowanseymour 106 | * Improve tests and code quality by @rowanseymour 107 | * Add performance tests by @rowanseymour 108 | * Tag names can include a "-" (for angular for example) by @rowanseymour 109 | * Don't uses tox anymore for testing by @rowanseymour 110 | * Classes shorthand can come before a id one by @rowanseymour 111 | * Added co-maintainership guidelines by @psycojoker 112 | 113 | 0.85 (2016-10-13) 114 | ================= 115 | 116 | * Python 3 support by @rowanseymour https://github.com/Psycojoker/django-hamlpy/pull/1 117 | * Attributes now output by alphabetic order (this was needed to have deterministic tests) by @rowanseymour https://github.com/Psycojoker/django-hamlpy/pull/2 118 | 119 | 0.84 (2016-09-25) 120 | ================= 121 | 122 | * Add support for boolean attributes - see [Attributes without values (Boolean attributes)](http://github.com/psycojoker/django-hamlpy/blob/master/reference.md#attributes-without-values-boolean-attributes) 123 | * Add Django class based generic views that looks for `*.haml` and `*.hamlpy` templates in additions of the normal ones (https://github.com/Psycojoker/django-hamlpy#class-based-generic-views) 124 | 125 | 0.83 (2016-09-23) 126 | ================= 127 | 128 | * New fork since sadly hamlpy isn't maintained anymore 129 | * Pypi release have been renamed "django-hamlpy" instead of "hamlpy" 130 | * Added Django 1.9 compatibility (https://github.com/jessemiller/HamlPy/pull/166) 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2011 Jesse Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [](https://github.com/nyaruka/django-hamlpy/actions?query=workflow%3ACI) 4 | [](https://codecov.io/gh/nyaruka/django-hamlpy) 5 | [](https://pypi.python.org/pypi/django-hamlpy/) 6 | 7 | Why type: 8 | 9 | ```html 10 |
The magical fruit
174 |foo
345 |bar
346 | ``` 347 | 348 | ## Django Specific Elements 349 | 350 | The key difference in HamlPy from Haml is the support for Django elements. The syntax for ruby evaluation is borrowed from Haml and instead outputs Django tags and variables. 351 | 352 | ### Django Variables: = 353 | 354 | A line starting with an equal sign followed by a space and then content is evaluated as a Django variable. For example: 355 | 356 | ```haml 357 | .article 358 | .preview 359 | = story.teaser 360 | ``` 361 | 362 | is compiled to: 363 | 364 | ```htmldjango 365 |{{ Foo }}494 |
item %s
" % i 563 | ``` 564 | 565 | is compiled to: 566 | 567 | ```htmldjango 568 |item 0
569 |item 1
570 |item 2
571 |item 3
572 |item 4
573 | ``` 574 | 575 | -------------------------------------------------------------------------------- /hamlpy/__init__.py: -------------------------------------------------------------------------------- 1 | HAML_EXTENSIONS = ("haml", "hamlpy") 2 | -------------------------------------------------------------------------------- /hamlpy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Config(AppConfig): 5 | name = "hamlpy" 6 | 7 | def ready(self): 8 | # patch Django's templatize method 9 | from .template import templatize # noqa 10 | 11 | 12 | default_app_config = "hamlpy.Config" 13 | -------------------------------------------------------------------------------- /hamlpy/compiler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import warnings 4 | 5 | import regex 6 | 7 | from hamlpy.parser.core import Stream 8 | from hamlpy.parser.nodes import Node, read_node 9 | 10 | 11 | class Options(object): 12 | HTML4 = "html4" 13 | HTML5 = "html5" 14 | XHTML = "xhtml" 15 | 16 | def __init__(self, **kwargs): 17 | # standard Haml options 18 | self.attr_wrapper = "'" # how to render attribute values, e.g. foo='bar' 19 | self.format = self.HTML5 # HTML4, HTML5 or XHTML 20 | self.escape_attrs = False # escape HTML-sensitive characters in attribute values 21 | self.cdata = False # wrap CSS, Javascript etc content in CDATA section 22 | 23 | # implementation specific options 24 | self.django_inline_style = False # support both #{...} and ={...} 25 | self.tag_config = "django" # Django vs Jinja2 tags 26 | self.custom_self_closing_tags = {} # additional self-closing tags 27 | self.endblock_names = False # include block name on endblock closing tags 28 | self.smart_quotes = False 29 | self.debug_tree = False 30 | 31 | for k, v in kwargs.items(): 32 | setattr(self, k, v) 33 | 34 | if self.django_inline_style: # pragma: no cover 35 | warnings.warn("Support for ={..} style variables is deprecated", DeprecationWarning) 36 | 37 | self.cdata = self._cdata 38 | 39 | @property 40 | def html4(self): 41 | return self.format == self.HTML4 42 | 43 | @property 44 | def html5(self): 45 | return self.format == self.HTML5 46 | 47 | @property 48 | def html(self): 49 | return self.html4 or self.html5 50 | 51 | @property 52 | def xhtml(self): 53 | return self.format == self.XHTML 54 | 55 | @property 56 | def _cdata(self): 57 | return self.xhtml or self.cdata 58 | 59 | 60 | class Compiler: 61 | TAG_CONFIGS = { 62 | "django": { 63 | "self_closing": { 64 | "for": "endfor", 65 | "if": "endif", 66 | "ifchanged": "endifchanged", 67 | "ifequal": "endifequal", 68 | "ifnotequal": "endifnotequal", 69 | "block": "endblock", 70 | "filter": "endfilter", 71 | "autoescape": "endautoescape", 72 | "with": "endwith", 73 | "blocktrans": "endblocktrans", 74 | "spaceless": "endspaceless", 75 | "comment": "endcomment", 76 | "cache": "endcache", 77 | "localize": "endlocalize", 78 | "call": "endcall", 79 | "macro": "endmacro", 80 | "compress": "endcompress", 81 | }, 82 | "may_contain": { 83 | "blocktrans": ["plural"], 84 | "if": ["else", "elif"], 85 | "ifchanged": ["else"], 86 | "ifequal": ["else"], 87 | "ifnotequal": ["else"], 88 | "for": ["empty"], 89 | }, 90 | }, 91 | "jinja2": { 92 | "self_closing": { 93 | "for": "endfor", 94 | "if": "endif", 95 | "block": "endblock", 96 | "filter": "endfilter", 97 | "with": "endwith", 98 | "call": "endcall", 99 | "macro": "endmacro", 100 | "raw": "endraw", 101 | }, 102 | "may_contain": {"if": ["else", "elif"], "for": ["empty", "else"]}, 103 | }, 104 | } 105 | 106 | def __init__(self, options=None): 107 | self.options = Options(**(options or {})) 108 | 109 | tag_config = self.TAG_CONFIGS[self.options.tag_config] 110 | self.self_closing_tags = tag_config["self_closing"] 111 | self.tags_may_contain = tag_config["may_contain"] 112 | 113 | self.self_closing_tags.update(self.options.custom_self_closing_tags) 114 | 115 | self.inline_variable_regexes = self._create_inline_variable_regexes() 116 | 117 | def process(self, haml): 118 | """ 119 | Converts the given string of Haml to a regular Django HTML 120 | """ 121 | stream = Stream(haml) 122 | 123 | root = Node.create_root(self) 124 | node = None 125 | 126 | while True: 127 | node = read_node(stream, prev=node, compiler=self) 128 | if not node: 129 | break 130 | 131 | root.add_node(node) 132 | 133 | if self.options.debug_tree: # pragma: no cover 134 | return root.debug_tree() 135 | else: 136 | return root.render() 137 | 138 | def _create_inline_variable_regexes(self): 139 | """ 140 | Generates regular expressions for inline variables and escaped inline variables, based on compiler options 141 | """ 142 | prefixes = ["=", "#"] if self.options.django_inline_style else ["#"] 143 | prefixes = "".join(prefixes) 144 | return ( 145 | regex.compile(r"(? timestamp 28 | compiled = dict() 29 | 30 | 31 | class StoreNameValueTagPair(argparse.Action): 32 | def __call__(self, parser, namespace, values, option_string=None): 33 | tags = getattr(namespace, "tags", {}) 34 | for item in values: 35 | n, v = item.split(":") 36 | tags[n] = v 37 | 38 | setattr(namespace, "tags", tags) 39 | 40 | 41 | arg_parser = argparse.ArgumentParser() 42 | arg_parser.add_argument("-v", "--verbose", help="Display verbose output", action="store_true") 43 | arg_parser.add_argument( 44 | "-i", "--input-extension", metavar="EXT", help="The file extensions to look for.", type=str, nargs="+" 45 | ) 46 | arg_parser.add_argument( 47 | "-ext", 48 | "--extension", 49 | metavar="EXT", 50 | default=Options.OUTPUT_EXT, 51 | help="The output file extension. Default is .html", 52 | type=str, 53 | ) 54 | arg_parser.add_argument( 55 | "-r", 56 | "--refresh", 57 | metavar="S", 58 | default=Options.CHECK_INTERVAL, 59 | type=int, 60 | help="Refresh interval for files. Default is %d seconds. Ignored if the --once flag is set." 61 | % Options.CHECK_INTERVAL, 62 | ) 63 | arg_parser.add_argument("input_dir", help="Folder to watch", type=str) 64 | arg_parser.add_argument("output_dir", help="Destination folder", type=str, nargs="?") 65 | arg_parser.add_argument( 66 | "--tag", type=str, nargs=1, action=StoreNameValueTagPair, help="Add self closing tag. eg. --tag macro:endmacro" 67 | ) 68 | arg_parser.add_argument( 69 | "--attr-wrapper", 70 | dest="attr_wrapper", 71 | type=str, 72 | choices=('"', "'"), 73 | default="'", 74 | action="store", 75 | help="The character that should wrap element attributes. This defaults to ' (an apostrophe).", 76 | ) 77 | arg_parser.add_argument( 78 | "--django-inline", 79 | dest="django_inline", 80 | action="store_true", 81 | help="Whether to support ={...} syntax for inline variables in addition to #{...}", 82 | ) 83 | arg_parser.add_argument( 84 | "--jinja", default=False, action="store_true", help="Makes the necessary changes to be used with Jinja2." 85 | ) 86 | arg_parser.add_argument( 87 | "--once", 88 | default=False, 89 | action="store_true", 90 | help="Runs the compiler once and exits on completion. " 91 | "Returns a non-zero exit code if there were any compile errors.", 92 | ) 93 | 94 | 95 | def watch_folder(): 96 | """Main entry point. Expects one or two arguments (the watch folder + optional destination folder).""" 97 | args = arg_parser.parse_args(sys.argv[1:]) 98 | compiler_args = {} 99 | 100 | input_folder = os.path.realpath(args.input_dir) 101 | if not args.output_dir: 102 | output_folder = input_folder 103 | else: 104 | output_folder = os.path.realpath(args.output_dir) 105 | 106 | if args.verbose: 107 | Options.VERBOSE = True 108 | print("Watching {} at refresh interval {} seconds".format(input_folder, args.refresh)) 109 | 110 | if args.extension: 111 | Options.OUTPUT_EXT = args.extension 112 | 113 | if getattr(args, "tags", False): 114 | compiler_args["custom_self_closing_tags"] = args.tags 115 | 116 | if args.input_extension: 117 | input_extensions = [(e[1:] if e.startswith(".") else e) for e in args.input_extension] # strip . chars 118 | else: 119 | input_extensions = HAML_EXTENSIONS 120 | 121 | if args.attr_wrapper: 122 | compiler_args["attr_wrapper"] = args.attr_wrapper 123 | 124 | if args.django_inline: 125 | compiler_args["django_inline_style"] = args.django_inline 126 | 127 | if args.jinja: 128 | compiler_args["tag_config"] = "jinja2" 129 | 130 | # compile once, then exist 131 | if args.once: 132 | (total_files, num_failed) = _watch_folder(input_folder, input_extensions, output_folder, compiler_args) 133 | print("Compiled %d of %d files." % (total_files - num_failed, total_files)) 134 | if num_failed == 0: 135 | print("All files compiled successfully.") 136 | else: 137 | print("Some files have errors.") 138 | sys.exit(num_failed) 139 | 140 | while True: 141 | try: 142 | _watch_folder(input_folder, input_extensions, output_folder, compiler_args) 143 | time.sleep(args.refresh) 144 | except KeyboardInterrupt: 145 | # allow graceful exit (no stacktrace output) 146 | sys.exit(0) 147 | 148 | 149 | def _watch_folder(folder, extensions, destination, compiler_args): 150 | """ 151 | Compares "modified" timestamps against the "compiled" dict, calls compiler if necessary. Returns a tuple of the 152 | number of files hit and the number of failed compiles 153 | """ 154 | total_files = 0 155 | num_failed = 0 156 | 157 | if Options.VERBOSE: 158 | print("_watch_folder(%s, %s, %s)" % (folder, repr(extensions), destination)) 159 | 160 | for dirpath, dirnames, filenames in os.walk(folder): 161 | for filename in filenames: 162 | # ignore filenames starting with ".#" for Emacs compatibility 163 | if filename.startswith(".#"): 164 | continue 165 | 166 | if not _has_extension(filename, extensions): 167 | continue 168 | 169 | fullpath = os.path.join(dirpath, filename) 170 | subfolder = os.path.relpath(dirpath, folder) 171 | mtime = os.stat(fullpath).st_mtime 172 | 173 | # create subfolders in target directory if they don't exist 174 | compiled_folder = os.path.join(destination, subfolder) 175 | if not os.path.exists(compiled_folder): 176 | os.makedirs(compiled_folder) 177 | 178 | compiled_path = _compiled_path(compiled_folder, filename) 179 | if fullpath not in compiled or compiled[fullpath] < mtime or not os.path.isfile(compiled_path): 180 | compiled[fullpath] = mtime 181 | total_files += 1 182 | if not compile_file(fullpath, compiled_path, compiler_args): 183 | num_failed += 1 184 | 185 | return total_files, num_failed 186 | 187 | 188 | def _has_extension(filename, extensions): 189 | """ 190 | Checks if the given filename has one of the given extensions 191 | """ 192 | for ext in extensions: 193 | if filename.endswith("." + ext): 194 | return True 195 | return False 196 | 197 | 198 | def _compiled_path(destination, filename): 199 | return os.path.join(destination, filename[: filename.rfind(".")] + Options.OUTPUT_EXT) 200 | 201 | 202 | def compile_file(fullpath, outfile_name, compiler_args): 203 | """ 204 | Calls HamlPy compiler. Returns True if the file was compiled and written successfully. 205 | """ 206 | if Options.VERBOSE: 207 | print("%s %s -> %s" % (strftime("%H:%M:%S"), fullpath, outfile_name)) 208 | try: 209 | if Options.DEBUG: # pragma: no cover 210 | print("Compiling %s -> %s" % (fullpath, outfile_name)) 211 | haml = codecs.open(fullpath, "r", encoding="utf-8").read() 212 | compiler = Compiler(compiler_args) 213 | output = compiler.process(haml) 214 | outfile = codecs.open(outfile_name, "w", encoding="utf-8") 215 | outfile.write(output) 216 | 217 | return True 218 | except Exception as e: 219 | # import traceback 220 | print("Failed to compile %s -> %s\nReason:\n%s" % (fullpath, outfile_name, e)) 221 | # print traceback.print_exc() 222 | 223 | return False 224 | 225 | 226 | if __name__ == "__main__": # pragma: no cover 227 | watch_folder() 228 | -------------------------------------------------------------------------------- /hamlpy/jinja.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import jinja2.ext 4 | 5 | from hamlpy import HAML_EXTENSIONS 6 | from hamlpy.compiler import Compiler 7 | from hamlpy.parser.core import ParseException 8 | 9 | 10 | class HamlPyExtension(jinja2.ext.Extension): 11 | def preprocess(self, source, name, filename=None): 12 | extension = os.path.splitext(name)[1][1:] 13 | 14 | if extension in HAML_EXTENSIONS: 15 | compiler = Compiler() 16 | try: 17 | return compiler.process(source) 18 | except ParseException as e: 19 | raise jinja2.TemplateSyntaxError(str(e), 1, name=name, filename=filename) 20 | else: 21 | return source 22 | -------------------------------------------------------------------------------- /hamlpy/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/django-hamlpy/59e87ee4fceb84adeabef8358a95572544683c7a/hamlpy/parser/__init__.py -------------------------------------------------------------------------------- /hamlpy/parser/attributes.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import regex 4 | 5 | from .core import ( 6 | STRING_LITERALS, 7 | WHITESPACE_CHARS, 8 | ParseException, 9 | peek_indentation, 10 | read_line, 11 | read_number, 12 | read_quoted_string, 13 | read_symbol, 14 | read_whitespace, 15 | read_word, 16 | ) 17 | from .utils import html_escape 18 | 19 | LEADING_SPACES_REGEX = regex.compile(r"^\s+", regex.V1 | regex.MULTILINE) 20 | 21 | # non-word characters that we allow in attribute keys (in HTML style attribute dicts) 22 | ATTRIBUTE_KEY_EXTRA_CHARS = {":", "-", "$", "?", "[", "]"} 23 | 24 | ATTRIBUTE_VALUE_KEYWORDS = {"none": None, "true": True, "false": False} 25 | 26 | 27 | def read_attribute_value(stream, compiler): 28 | """ 29 | Reads an attribute's value which may be a string, a number or None 30 | """ 31 | ch = stream.text[stream.ptr] 32 | 33 | if ch in STRING_LITERALS: 34 | value = read_quoted_string(stream) 35 | 36 | if compiler.options.escape_attrs: 37 | # TODO handle escape_attrs=once 38 | value = html_escape(value) 39 | 40 | elif ch.isdigit(): 41 | value = read_number(stream) 42 | else: 43 | raw_value = read_word(stream) 44 | 45 | if raw_value.lower() in ATTRIBUTE_VALUE_KEYWORDS: 46 | value = ATTRIBUTE_VALUE_KEYWORDS[raw_value.lower()] 47 | else: 48 | value = "{{ %s }}" % raw_value 49 | 50 | return value 51 | 52 | 53 | def read_attribute_value_list(stream, compiler): 54 | """ 55 | Reads an attribute value which is a list of other values 56 | """ 57 | open_literal = stream.text[stream.ptr] 58 | 59 | assert open_literal in ("(", "[") 60 | 61 | read_tuple = open_literal == "(" 62 | close_literal = ")" if read_tuple else "]" 63 | 64 | data = [] 65 | 66 | stream.ptr += 1 # consume opening symbol 67 | 68 | while True: 69 | read_whitespace(stream) 70 | 71 | if stream.text[stream.ptr] == close_literal: 72 | break 73 | 74 | data.append(read_attribute_value(stream, compiler)) 75 | 76 | read_whitespace(stream) 77 | 78 | if stream.text[stream.ptr] != close_literal: 79 | read_symbol(stream, (",",)) 80 | 81 | stream.ptr += 1 # consume closing symbol 82 | 83 | return data 84 | 85 | 86 | def read_attribute_value_haml(stream, compiler): 87 | """ 88 | Reads an attribute value which is a block of indented Haml 89 | """ 90 | indentation = peek_indentation(stream) 91 | haml_lines = [] 92 | 93 | # read lines below with higher indentation as this filter's content 94 | while stream.ptr < stream.length: 95 | line_indentation = peek_indentation(stream) 96 | 97 | if line_indentation is not None and line_indentation < indentation: 98 | break 99 | 100 | line = read_line(stream) 101 | 102 | # don't preserve whitespace on empty lines 103 | if line.isspace(): 104 | line = "" 105 | 106 | haml_lines.append(line) 107 | 108 | stream.ptr -= 1 # un-consume final newline which will act as separator between this and next entry 109 | 110 | haml = "\n".join(haml_lines) 111 | html = compiler.process(haml) 112 | 113 | # un-format into single line 114 | return LEADING_SPACES_REGEX.sub(" ", html).replace("\n", "").strip() 115 | 116 | 117 | def read_ruby_attribute(stream, compiler): 118 | """ 119 | Reads a Ruby style attribute, e.g. :foo => "bar" or foo: "bar" 120 | """ 121 | old_style = stream.text[stream.ptr] == ":" 122 | 123 | if old_style: 124 | stream.ptr += 1 125 | 126 | key = read_word(stream, include_chars=("-",)) 127 | else: 128 | # new style Ruby / Python style allows attribute to be quoted string 129 | if stream.text[stream.ptr] in STRING_LITERALS: 130 | key = read_quoted_string(stream) 131 | 132 | if not key: 133 | raise ParseException("Attribute name can't be an empty string.", stream) 134 | else: 135 | key = read_word(stream, include_chars=("-",)) 136 | 137 | read_whitespace(stream) 138 | 139 | if stream.text[stream.ptr] in ("=", ":"): 140 | if old_style: 141 | read_symbol(stream, ("=>",)) 142 | else: 143 | read_symbol(stream, (":",)) 144 | 145 | read_whitespace(stream, include_newlines=False) 146 | 147 | stream.expect_input() 148 | 149 | if stream.text[stream.ptr] == "\n": 150 | stream.ptr += 1 151 | 152 | value = read_attribute_value_haml(stream, compiler) 153 | elif stream.text[stream.ptr] in ("(", "["): 154 | value = read_attribute_value_list(stream, compiler) 155 | else: 156 | value = read_attribute_value(stream, compiler) 157 | else: 158 | value = True 159 | 160 | return key, value 161 | 162 | 163 | def read_html_attribute(stream, compiler): 164 | """ 165 | Reads an HTML style attribute, e.g. foo="bar" 166 | """ 167 | key = read_word(stream, include_chars=ATTRIBUTE_KEY_EXTRA_CHARS) 168 | 169 | # can't have attributes without whitespace separating them 170 | ch = stream.text[stream.ptr] 171 | if ch not in WHITESPACE_CHARS and ch not in ("=", ")"): 172 | stream.raise_unexpected() 173 | 174 | read_whitespace(stream) 175 | 176 | if stream.text[stream.ptr] == "=": 177 | read_symbol(stream, "=") 178 | 179 | read_whitespace(stream, include_newlines=False) 180 | 181 | stream.expect_input() 182 | 183 | if stream.text[stream.ptr] == "\n": 184 | stream.ptr += 1 185 | 186 | value = read_attribute_value_haml(stream, compiler) 187 | elif stream.text[stream.ptr] == "[": 188 | value = read_attribute_value_list(stream, compiler) 189 | else: 190 | value = read_attribute_value(stream, compiler) 191 | else: 192 | value = True 193 | 194 | return key, value 195 | 196 | 197 | def read_attribute_dict(stream, compiler): 198 | """ 199 | Reads an attribute dictionary which may use one of 3 syntaxes: 200 | 1. {:foo => "bar", :a => 3} (old Ruby) 201 | 2. {foo: "bar", a: 3} (new Ruby / Python) 202 | 3. (foo="bar" a=3) (HTML) 203 | """ 204 | data = OrderedDict() 205 | 206 | opener = stream.text[stream.ptr] 207 | 208 | assert opener in ("{", "(") 209 | 210 | if opener == "(": 211 | html_style = True 212 | terminator = ")" 213 | else: 214 | html_style = False 215 | terminator = "}" 216 | 217 | stream.ptr += 1 218 | 219 | def record_value(key, value): 220 | if key in data: 221 | raise ParseException('Duplicate attribute: "%s".' % key, stream) 222 | data[key] = value 223 | 224 | while True: 225 | read_whitespace(stream, include_newlines=True) 226 | 227 | if stream.ptr >= stream.length: 228 | raise ParseException("Unterminated attribute dictionary", stream) 229 | 230 | if stream.text[stream.ptr] == terminator: 231 | stream.ptr += 1 232 | break 233 | 234 | # (foo = "bar" a=3) 235 | if html_style: 236 | record_value(*read_html_attribute(stream, compiler)) 237 | 238 | read_whitespace(stream) 239 | 240 | if stream.text[stream.ptr] == ",": 241 | raise ParseException('Unexpected ",".', stream) 242 | 243 | # {:foo => "bar", :a=>3} or {foo: "bar", a: 3} 244 | else: 245 | record_value(*read_ruby_attribute(stream, compiler)) 246 | 247 | read_whitespace(stream) 248 | 249 | if stream.text[stream.ptr] not in (terminator, "\n"): 250 | read_symbol(stream, (",",)) 251 | 252 | return data 253 | -------------------------------------------------------------------------------- /hamlpy/parser/core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | STRING_LITERALS = ('"', "'") 4 | WHITESPACE_CHARS = (" ", "\t") 5 | WHITESPACE_AND_NEWLINE_CHARS = (" ", "\t", "\r", "\n") 6 | 7 | 8 | class ParseException(Exception): 9 | def __init__(self, message, stream=None): 10 | if stream: 11 | context = stream.text[max(stream.ptr - 31, 0) : stream.ptr + 1] 12 | message = '%s @ "%s" <-' % (message, context) 13 | 14 | super(ParseException, self).__init__(message) 15 | 16 | 17 | class Stream(object): 18 | def __init__(self, text): 19 | self.text = text 20 | self.length = len(self.text) 21 | self.ptr = 0 22 | 23 | def expect_input(self): 24 | """ 25 | We expect more input, raise exception if there isn't any 26 | """ 27 | if self.ptr >= self.length: 28 | raise ParseException("Unexpected end of input.", self) 29 | 30 | def raise_unexpected(self): 31 | """ 32 | Raises exception that current character is unexpected 33 | """ 34 | raise ParseException('Unexpected "%s".' % self.text[self.ptr], self) 35 | 36 | def __repr__(self): # pragma: no cover 37 | return '"%s" >> "%s"' % ( 38 | self.text[: self.ptr].replace("\n", "\\n"), 39 | self.text[self.ptr :].replace("\n", "\\n"), 40 | ) 41 | 42 | 43 | class TreeNode(object): 44 | """ 45 | Generic parent/child tree class 46 | """ 47 | 48 | def __init__(self): 49 | self.parent = None 50 | self.children = [] 51 | 52 | def left_sibling(self): 53 | siblings = self.parent.children 54 | index = siblings.index(self) 55 | return siblings[index - 1] if index > 0 else None 56 | 57 | def right_sibling(self): 58 | siblings = self.parent.children 59 | index = siblings.index(self) 60 | return siblings[index + 1] if index < len(siblings) - 1 else None 61 | 62 | def add_child(self, child): 63 | child.parent = self 64 | self.children.append(child) 65 | 66 | 67 | def read_whitespace(stream, include_newlines=False): 68 | """ 69 | Reads whitespace characters, returning the whitespace characters 70 | """ 71 | whitespace = WHITESPACE_AND_NEWLINE_CHARS if include_newlines else WHITESPACE_CHARS 72 | 73 | start = stream.ptr 74 | 75 | while stream.ptr < stream.length and stream.text[stream.ptr] in whitespace: 76 | stream.ptr += 1 77 | 78 | return stream.text[start : stream.ptr] 79 | 80 | 81 | def peek_indentation(stream): 82 | """ 83 | Counts but doesn't actually read indentation level on new line, returning the count or None if line is blank 84 | """ 85 | indentation = 0 86 | while True: 87 | ch = stream.text[stream.ptr + indentation] 88 | if ch == "\n": 89 | return None 90 | 91 | if not ch.isspace(): 92 | return indentation 93 | 94 | indentation += 1 95 | 96 | 97 | def read_quoted_string(stream): 98 | """ 99 | Reads a single or double quoted string, returning the value without the quotes 100 | """ 101 | terminator = stream.text[stream.ptr] 102 | 103 | assert terminator in STRING_LITERALS 104 | 105 | start = stream.ptr 106 | stream.ptr += 1 # consume opening quote 107 | 108 | while True: 109 | if stream.ptr >= stream.length: 110 | raise ParseException("Unterminated string (expected %s)." % terminator, stream) 111 | 112 | if stream.text[stream.ptr] == terminator and stream.text[stream.ptr - 1] != "\\": 113 | break 114 | 115 | stream.ptr += 1 116 | 117 | stream.ptr += 1 # consume closing quote 118 | 119 | # evaluate as a Python string (evaluates escape sequences) 120 | return ast.literal_eval(stream.text[start : stream.ptr]) 121 | 122 | 123 | def read_line(stream): 124 | """ 125 | Reads a line 126 | """ 127 | start = stream.ptr 128 | 129 | if stream.ptr >= stream.length: 130 | return None 131 | 132 | while stream.ptr < stream.length and stream.text[stream.ptr] != "\n": 133 | stream.ptr += 1 134 | 135 | line = stream.text[start : stream.ptr] 136 | 137 | if stream.ptr < stream.length and stream.text[stream.ptr] == "\n": 138 | stream.ptr += 1 139 | 140 | return line 141 | 142 | 143 | def read_number(stream): 144 | """ 145 | Reads a decimal number, returning value as string 146 | """ 147 | start = stream.ptr 148 | 149 | while True: 150 | if not stream.text[stream.ptr].isdigit() and stream.text[stream.ptr] != ".": 151 | break 152 | 153 | stream.ptr += 1 154 | 155 | return stream.text[start : stream.ptr] 156 | 157 | 158 | def read_symbol(stream, symbols): 159 | """ 160 | Reads one of the given symbols, returning its value 161 | """ 162 | for symbol in symbols: 163 | if stream.text[stream.ptr : stream.ptr + len(symbol)] == symbol: 164 | stream.ptr += len(symbol) 165 | return symbol 166 | 167 | raise ParseException("Expected %s." % " or ".join(['"%s"' % s for s in symbols]), stream) 168 | 169 | 170 | def read_word(stream, include_chars=()): 171 | """ 172 | Reads a sequence of word characters 173 | """ 174 | stream.expect_input() 175 | 176 | start = stream.ptr 177 | 178 | while stream.ptr < stream.length: 179 | ch = stream.text[stream.ptr] 180 | if not (ch.isalnum() or ch == "_" or ch in include_chars): 181 | break 182 | stream.ptr += 1 183 | 184 | # if we immediately hit a non-word character, raise it as unexpected 185 | if start == stream.ptr: 186 | stream.raise_unexpected() 187 | 188 | return stream.text[start : stream.ptr] 189 | -------------------------------------------------------------------------------- /hamlpy/parser/elements.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from .attributes import read_attribute_dict 4 | from .core import read_line, read_word 5 | 6 | # non-word characters that we allow in tag names, ids and classes 7 | TAG_EXTRA_CHARS = ("-",) 8 | 9 | # non-word characters that we allow in ids and classes 10 | ID_OR_CLASS_EXTRA_CHARS = ("-", ":") 11 | 12 | 13 | def read_tag(stream): 14 | """ 15 | Reads an element tag, e.g. span, ng-repeat, cs:dropdown 16 | """ 17 | part1 = read_word(stream, TAG_EXTRA_CHARS) 18 | 19 | if stream.ptr < stream.length and stream.text[stream.ptr] == ":": 20 | stream.ptr += 1 21 | part2 = read_word(stream, TAG_EXTRA_CHARS) 22 | else: 23 | part2 = None 24 | 25 | return (part1 + ":" + part2) if part2 else part1 26 | 27 | 28 | def read_element(stream, compiler): 29 | """ 30 | Reads an element, e.g. %span, #banner{style:"width: 100px"}, .ng-hide(foo=1) 31 | """ 32 | assert stream.text[stream.ptr] in ("%", ".", "#") 33 | 34 | tag = None 35 | empty_class = False 36 | 37 | if stream.text[stream.ptr] == "%": 38 | stream.ptr += 1 39 | tag = read_tag(stream) 40 | 41 | elif stream.text[stream.ptr] == ".": 42 | # Element may start with a period representing an unidentified div rather than a CSS class. In this case it 43 | # can't have other classes or ids, e.g. .{foo:"bar"} 44 | next_ch = stream.text[stream.ptr + 1] if stream.ptr < stream.length - 1 else None 45 | if not (next_ch.isalnum() or next_ch == "_" or next_ch in ID_OR_CLASS_EXTRA_CHARS): 46 | stream.ptr += 1 47 | empty_class = True 48 | 49 | _id = None 50 | classes = [] 51 | 52 | if not empty_class: 53 | while stream.ptr < stream.length and stream.text[stream.ptr] in ("#", "."): 54 | is_id = stream.text[stream.ptr] == "#" 55 | stream.ptr += 1 56 | 57 | id_or_class = read_word(stream, ID_OR_CLASS_EXTRA_CHARS) 58 | if is_id: 59 | _id = id_or_class 60 | else: 61 | classes.append(id_or_class) 62 | 63 | attributes = OrderedDict() 64 | while stream.ptr < stream.length and stream.text[stream.ptr] in ("{", "("): 65 | attributes.update(read_attribute_dict(stream, compiler)) 66 | 67 | if stream.ptr < stream.length and stream.text[stream.ptr] == ">": 68 | stream.ptr += 1 69 | nuke_outer_ws = True 70 | else: 71 | nuke_outer_ws = False 72 | 73 | if stream.ptr < stream.length and stream.text[stream.ptr] == "<": 74 | stream.ptr += 1 75 | nuke_inner_ws = True 76 | else: 77 | nuke_inner_ws = False 78 | 79 | if stream.ptr < stream.length and stream.text[stream.ptr] == "/": 80 | stream.ptr += 1 81 | self_close = True 82 | else: 83 | self_close = tag in Element.SELF_CLOSING 84 | 85 | if stream.ptr < stream.length and stream.text[stream.ptr] == "=": 86 | stream.ptr += 1 87 | django_variable = True 88 | else: 89 | django_variable = False 90 | 91 | if stream.ptr < stream.length: 92 | inline = read_line(stream) 93 | if inline is not None: 94 | inline = inline.strip() 95 | else: 96 | inline = None 97 | 98 | return Element(tag, _id, classes, attributes, nuke_outer_ws, nuke_inner_ws, self_close, django_variable, inline) 99 | 100 | 101 | class Element(object): 102 | """ 103 | An HTML element with an id, classes, attributes etc 104 | """ 105 | 106 | SELF_CLOSING = ( 107 | "meta", 108 | "img", 109 | "link", 110 | "br", 111 | "hr", 112 | "input", 113 | "source", 114 | "track", 115 | "area", 116 | "base", 117 | "col", 118 | "command", 119 | "embed", 120 | "keygen", 121 | "param", 122 | "wbr", 123 | ) 124 | 125 | DEFAULT_TAG = "div" 126 | ESCAPED = {'"': """, "'": "'"} 127 | 128 | def __init__( 129 | self, 130 | tag, 131 | _id, 132 | classes, 133 | attributes, 134 | nuke_outer_whitespace, 135 | nuke_inner_whitespace, 136 | self_close, 137 | django_variable, 138 | inline_content, 139 | ): 140 | self.tag = tag or self.DEFAULT_TAG 141 | self.attributes = attributes 142 | self.nuke_inner_whitespace = nuke_inner_whitespace 143 | self.nuke_outer_whitespace = nuke_outer_whitespace 144 | self.self_close = self_close 145 | self.django_variable = django_variable 146 | self.inline_content = inline_content 147 | 148 | # merge ids from the attribute dictionary 149 | ids = [_id] if _id else [] 150 | id_from_attrs = attributes.get("id") 151 | if isinstance(id_from_attrs, (tuple, list)): 152 | ids += id_from_attrs 153 | elif isinstance(id_from_attrs, str): 154 | ids += [id_from_attrs] 155 | 156 | # merge ids to a single value with _ separators 157 | self.id = "_".join(ids) if ids else None 158 | 159 | # merge classes from the attribute dictionary 160 | class_from_attrs = attributes.get("class", []) 161 | if not isinstance(class_from_attrs, (tuple, list)): 162 | class_from_attrs = [class_from_attrs] 163 | 164 | self.classes = class_from_attrs + classes 165 | 166 | def render_attributes(self, options): 167 | def attr_wrap(val): 168 | return "%s%s%s" % (options.attr_wrapper, val, options.attr_wrapper) 169 | 170 | rendered = [] 171 | 172 | for name, value in self.attributes.items(): 173 | if name in ("id", "class") or value in (None, False): 174 | # this line isn't recorded in coverage because it gets optimized away (http://bugs.python.org/issue2506) 175 | continue # pragma: no cover 176 | 177 | if value is True: # boolean attribute 178 | if options.xhtml: 179 | rendered.append("%s=%s" % (name, attr_wrap(name))) 180 | else: 181 | rendered.append(name) 182 | else: 183 | value = self._escape_attribute_quotes(value, options.attr_wrapper, options.smart_quotes) 184 | rendered.append("%s=%s" % (name, attr_wrap(value))) 185 | 186 | if len(self.classes) > 0: 187 | rendered.append("class=%s" % attr_wrap(" ".join(self.classes))) 188 | 189 | if self.id: 190 | rendered.append("id=%s" % attr_wrap(self.id)) 191 | 192 | return " ".join(rendered) 193 | 194 | @classmethod 195 | def _escape_attribute_quotes(cls, v, attr_wrapper, smart_quotes=False): 196 | """ 197 | Escapes quotes, except those inside a Django tag 198 | """ 199 | escaped = [] 200 | inside_tag = False 201 | for i, _ in enumerate(v): 202 | if v[i : i + 2] == "{%": 203 | inside_tag = True 204 | elif v[i : i + 2] == "%}": 205 | inside_tag = False 206 | 207 | if v[i] == attr_wrapper and not inside_tag: 208 | if smart_quotes and attr_wrapper in ('"', "'"): 209 | repl = '"' if v[i] == "'" else "'" 210 | else: 211 | repl = cls.ESCAPED[attr_wrapper] 212 | 213 | escaped.append(repl) 214 | else: 215 | escaped.append(v[i]) 216 | 217 | return "".join(escaped) 218 | -------------------------------------------------------------------------------- /hamlpy/parser/filters.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import StringIO 3 | 4 | from django.conf import settings 5 | 6 | from .core import ParseException 7 | from .utils import html_escape 8 | 9 | """ 10 | Core HamlPy filters. 11 | 12 | The implementation of these should match https://github.com/haml/haml/blob/master/lib/haml/filters.rb as closely as 13 | possible. Where we differ is that we don't compile Stylus, Coffeescript etc into CSS or Javascript - but place the 14 | content into suitable " % (type_attr, before, indent, text, after) 143 | 144 | 145 | def script_filter(text, mime_type, comment, options): 146 | indent = " " if options.cdata else " " 147 | text = text.rstrip().replace("\n", "\n" + indent) 148 | type_attr = " type=%(attr_wrapper)s%(mime_type)s%(attr_wrapper)s" % { 149 | "attr_wrapper": options.attr_wrapper, 150 | "mime_type": mime_type, 151 | } 152 | before, after = (" %s\n" % comment) if options.cdata else ("", "") 153 | 154 | return "" % (type_attr, before, indent, text, after) 155 | 156 | 157 | # ---------------------------------------------------------------------------------- 158 | # Filter registration 159 | # ---------------------------------------------------------------------------------- 160 | 161 | FILTERS = { 162 | "plain": plain, 163 | "preserve": preserve, 164 | "escaped": escaped, 165 | "cdata": cdata, 166 | "css": css, 167 | "stylus": stylus, 168 | "less": less, 169 | "sass": sass, 170 | "javascript": javascript, 171 | "coffee": coffee, 172 | "coffeescript": coffee, 173 | "markdown": markdown, 174 | "highlight": highlight, 175 | "python": python, 176 | } 177 | 178 | 179 | def register_filter(name, callback): 180 | FILTERS[name] = callback 181 | 182 | 183 | def get_filter(name): 184 | if name not in FILTERS: 185 | raise ParseException("No such filter: " + name) 186 | 187 | return FILTERS.get(name) 188 | -------------------------------------------------------------------------------- /hamlpy/parser/nodes.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from .core import ParseException, TreeNode, peek_indentation, read_line, read_whitespace 4 | from .elements import read_element 5 | from .filters import get_filter 6 | 7 | XHTML_DOCTYPES = { 8 | "1.1": '', # noqa 9 | "strict": '', # noqa 10 | "frameset": '', # noqa 11 | "mobile": '', # noqa 12 | "rdfa": '', # noqa 13 | "basic": '', # noqa 14 | "": '', # noqa 15 | } 16 | 17 | HTML4_DOCTYPES = { 18 | "strict": '', 19 | "frameset": '', 20 | "": '', 21 | } 22 | 23 | 24 | DOCTYPE_PREFIX = "!!!" 25 | ELEMENT_PREFIXES = ("%", "#", ".") 26 | HTML_COMMENT_PREFIX = "/" 27 | CONDITIONAL_COMMENT_PREFIX = "/[" 28 | HAML_COMMENT_PREFIX = "-#" 29 | VARIABLE_PREFIX = "=" 30 | TAG_PREFIX = "-" 31 | FILTER_PREFIX = ":" 32 | 33 | HAML_ESCAPE = "\\" 34 | 35 | 36 | def read_node(stream, prev, compiler): 37 | """ 38 | Reads a node, returning either the node or None if we've reached the end of the input 39 | """ 40 | while True: 41 | indent = read_whitespace(stream) 42 | 43 | if stream.ptr >= stream.length: 44 | return None 45 | 46 | # convert indent to be all of the first character 47 | if indent: 48 | indent = indent[0] * len(indent) 49 | 50 | # empty lines are recorded as newlines on previous node 51 | if stream.text[stream.ptr] == "\n": 52 | if prev: 53 | prev.newlines += 1 54 | stream.ptr += 1 55 | continue 56 | 57 | # parse filter node 58 | if stream.text[stream.ptr] == FILTER_PREFIX: 59 | return read_filter_node(stream, indent, compiler) 60 | 61 | # peek ahead to so we don't try to parse an element from a variable node starting #{ or a Django tag ending %} 62 | if stream.text[stream.ptr] in ELEMENT_PREFIXES and stream.text[stream.ptr : stream.ptr + 2] not in ( 63 | "#{", 64 | "%}", 65 | ): 66 | element = read_element(stream, compiler) 67 | return ElementNode(element, indent, compiler) 68 | 69 | # all other nodes are single line 70 | line = read_line(stream) 71 | 72 | inline_var_regex, escaped_var_regex = compiler.inline_variable_regexes 73 | 74 | if inline_var_regex.match(line) or escaped_var_regex.match(line): 75 | return PlaintextNode(line, indent, compiler) 76 | 77 | if line[0] == HAML_ESCAPE: 78 | return PlaintextNode(line, indent, compiler) 79 | 80 | if line.startswith(DOCTYPE_PREFIX): 81 | return DoctypeNode(line, indent, compiler) 82 | 83 | if line.startswith(CONDITIONAL_COMMENT_PREFIX): 84 | return ConditionalCommentNode(line, indent, compiler) 85 | 86 | if line[0] == HTML_COMMENT_PREFIX: 87 | return CommentNode(line, indent, compiler) 88 | 89 | if line.startswith(HAML_COMMENT_PREFIX): 90 | return HamlCommentNode(line, indent, compiler) 91 | 92 | if line[0] == VARIABLE_PREFIX: 93 | return VariableNode(line, indent, compiler) 94 | 95 | if line[0] == TAG_PREFIX: 96 | return TagNode(line, indent, compiler) 97 | 98 | return PlaintextNode(line, indent, compiler) 99 | 100 | 101 | def read_filter_node(stream, indent, compiler): 102 | """ 103 | Reads a filter node including its indented content, e.g. :plain 104 | """ 105 | assert stream.text[stream.ptr] == FILTER_PREFIX 106 | 107 | stream.ptr += 1 # consume the initial colon 108 | name = read_line(stream) 109 | content_lines = [] 110 | 111 | # read lines below with higher indentation as this filter's content 112 | while stream.ptr < stream.length: 113 | line_indentation = peek_indentation(stream) 114 | 115 | if line_indentation is not None and line_indentation <= len(indent): 116 | break 117 | 118 | line = read_line(stream) 119 | 120 | # don't preserve whitespace on empty lines 121 | if line.isspace(): 122 | line = "" 123 | 124 | content_lines.append(line) 125 | 126 | return FilterNode(name.rstrip(), "\n".join(content_lines), indent, compiler) 127 | 128 | 129 | class Node(TreeNode): 130 | """ 131 | Base class of all nodes 132 | """ 133 | 134 | def __init__(self, indent, compiler): 135 | super(Node, self).__init__() 136 | 137 | if indent is not None: 138 | self.indent = indent 139 | self.indentation = len(indent) 140 | else: 141 | self.indent = None 142 | self.indentation = -1 143 | 144 | self.compiler = compiler 145 | 146 | self.newlines = 0 # number of empty lines to render after node 147 | self.before = "" # rendered text at start of node, e.g. "\n" 148 | self.after = "" # rendered text at end of node, e.g. "\n
" 149 | 150 | @classmethod 151 | def create_root(cls, compiler): 152 | return cls(None, compiler) 153 | 154 | def render(self): 155 | # Render (sets self.before and self.after) 156 | self._render_children() 157 | # Post-render (nodes can modify the rendered text of other nodes) 158 | self._post_render() 159 | # Generate HTML 160 | return self._generate_html() 161 | 162 | def render_newlines(self): 163 | return "\n" * (self.newlines + 1) 164 | 165 | def _render_children(self): 166 | for child in self.children: 167 | child._render() 168 | 169 | def _post_render(self): 170 | for child in self.children: 171 | child._post_render() 172 | 173 | def _generate_html(self): 174 | output = [self.before] 175 | for child in self.children: 176 | output.append(child.before) 177 | output += [gc._generate_html() for gc in child.children] 178 | output.append(child.after) 179 | output.append(self.after) 180 | return "".join(output) 181 | 182 | def replace_inline_variables(self, content): 183 | inline_var_regex, escaped_var_regex = self.compiler.inline_variable_regexes 184 | 185 | content = inline_var_regex.sub(r"{{ \2 }}", content) 186 | content = escaped_var_regex.sub(r"\1", content) 187 | return content 188 | 189 | def add_node(self, node): 190 | if self._should_go_inside_last_node(node): 191 | self.children[-1].add_node(node) 192 | else: 193 | self.add_child(node) 194 | 195 | def _should_go_inside_last_node(self, node): 196 | return len(self.children) > 0 and ( 197 | node.indentation > self.children[-1].indentation 198 | or (node.indentation == self.children[-1].indentation and self.children[-1].should_contain(node)) 199 | ) 200 | 201 | def should_contain(self, node): 202 | return False 203 | 204 | def debug_tree(self): # pragma: no cover 205 | return "\n".join(self._debug_tree([self])) 206 | 207 | def _debug_tree(self, nodes): # pragma: no cover 208 | output = [] 209 | for n in nodes: 210 | output.append("%s%s" % (" " * (n.indentation + 2), n)) 211 | if n.children: 212 | output += self._debug_tree(n.children) 213 | return output 214 | 215 | def __repr__(self): # pragma: no cover 216 | return "%s" % type(self).__name__ 217 | 218 | 219 | class LineNode(Node): 220 | """ 221 | Base class of nodes which are a single line of Haml 222 | """ 223 | 224 | def __init__(self, line, indent, compiler): 225 | super(LineNode, self).__init__(indent, compiler) 226 | 227 | self.haml = line.rstrip() 228 | 229 | def __repr__(self): # pragma: no cover 230 | return "%s(indent=%d, newlines=%d): %s" % (type(self).__name__, self.indentation, self.newlines, self.haml) 231 | 232 | 233 | class PlaintextNode(LineNode): 234 | """ 235 | Node that is not modified or processed when rendering 236 | """ 237 | 238 | def _render(self): 239 | text = self.replace_inline_variables(self.haml) 240 | 241 | # remove escape character 242 | if text and text[0] == HAML_ESCAPE: 243 | text = text.replace(HAML_ESCAPE, "", 1) 244 | 245 | self.before = "%s%s" % (self.indent, text) 246 | if self.children: 247 | self.before += self.render_newlines() 248 | else: 249 | self.after = self.render_newlines() 250 | 251 | self._render_children() 252 | 253 | 254 | class ElementNode(Node): 255 | """ 256 | An HTML tag node, e.g. %span 257 | """ 258 | 259 | def __init__(self, element, indent, compiler): 260 | super(ElementNode, self).__init__(indent, compiler) 261 | 262 | self.element = element 263 | 264 | def _render(self): 265 | self.before = self._render_before(self.element) 266 | self.after = self._render_after(self.element) 267 | self._render_children() 268 | 269 | def _render_before(self, element): 270 | """ 271 | Render opening tag and inline content 272 | """ 273 | start = ["%s<%s" % (self.indent, element.tag)] 274 | 275 | attributes = element.render_attributes(self.compiler.options) 276 | if attributes: 277 | start.append(" " + self.replace_inline_variables(attributes)) 278 | 279 | content = self._render_inline_content(self.element.inline_content) 280 | 281 | if element.nuke_inner_whitespace and content: 282 | content = content.strip() 283 | 284 | if element.self_close and not content: 285 | start.append(">" if self.compiler.options.html else " />") 286 | elif content: 287 | start.append(">%s" % content) 288 | elif self.children: 289 | start.append(">%s" % (self.render_newlines())) 290 | else: 291 | start.append(">") 292 | return "".join(start) 293 | 294 | def _render_after(self, element): 295 | """ 296 | Render closing tag 297 | """ 298 | if element.inline_content: 299 | return "%s>%s" % (element.tag, self.render_newlines()) 300 | elif element.self_close: 301 | return self.render_newlines() 302 | elif self.children: 303 | return "%s%s>\n" % (self.indent, element.tag) 304 | else: 305 | return "%s>\n" % element.tag 306 | 307 | def _post_render(self): 308 | # inner whitespace removal 309 | if self.element.nuke_inner_whitespace: 310 | self.before = self.before.rstrip() 311 | self.after = self.after.lstrip() 312 | 313 | if self.children: 314 | node = self 315 | if node.children: 316 | node.children[0].before = node.children[0].before.lstrip() 317 | 318 | if node.children: 319 | node.children[-1].after = node.children[-1].after.rstrip() 320 | 321 | # outer whitespace removal 322 | if self.element.nuke_outer_whitespace: 323 | left_sibling = self.left_sibling() 324 | if left_sibling: 325 | # If node has left sibling, strip whitespace after left sibling 326 | left_sibling.after = left_sibling.after.rstrip() 327 | left_sibling.newlines = 0 328 | else: 329 | # If not, whitespace comes from it's parent node, 330 | # so strip whitespace before the node 331 | self.parent.before = self.parent.before.rstrip() 332 | self.parent.newlines = 0 333 | 334 | self.before = self.before.lstrip() 335 | self.after = self.after.rstrip() 336 | 337 | right_sibling = self.right_sibling() 338 | if right_sibling: 339 | right_sibling.before = right_sibling.before.lstrip() 340 | else: 341 | self.parent.after = self.parent.after.lstrip() 342 | self.parent.newlines = 0 343 | 344 | super(ElementNode, self)._post_render() 345 | 346 | def _render_inline_content(self, inline_content): 347 | if inline_content is None or len(inline_content) == 0: 348 | return None 349 | 350 | if self.element.django_variable: 351 | content = "{{ " + inline_content.strip() + " }}" 352 | return content 353 | else: 354 | return self.replace_inline_variables(inline_content) 355 | 356 | 357 | class CommentNode(LineNode): 358 | """ 359 | An HTML comment node, e.g. / This is a comment 360 | """ 361 | 362 | def _render(self): 363 | self.after = "-->\n" 364 | if self.children: 365 | self.before = "\n" 386 | self._render_children() 387 | 388 | 389 | class DoctypeNode(LineNode): 390 | """ 391 | An XML doctype node, e.g. !!! 5 392 | """ 393 | 394 | def _render(self): 395 | doctype = self.haml.lstrip(DOCTYPE_PREFIX).strip().lower() 396 | 397 | self.before = self.get_header(doctype, self.compiler.options) 398 | self.after = self.render_newlines() 399 | 400 | def get_header(self, doctype, options): 401 | if doctype.startswith("xml"): 402 | if options.html: 403 | return "" 404 | parts = doctype.split() 405 | encoding = parts[1] if len(parts) > 1 else "utf-8" 406 | return "" % ( 407 | options.attr_wrapper, 408 | options.attr_wrapper, 409 | options.attr_wrapper, 410 | encoding, 411 | options.attr_wrapper, 412 | ) 413 | elif options.html5: 414 | return "" 415 | elif options.xhtml: 416 | if doctype == "5": 417 | return "" 418 | else: 419 | return XHTML_DOCTYPES.get(doctype, XHTML_DOCTYPES[""]) 420 | else: 421 | return HTML4_DOCTYPES.get(doctype, HTML4_DOCTYPES[""]) 422 | 423 | 424 | class HamlCommentNode(LineNode): 425 | """ 426 | A Haml comment node, e.g. -# This is a comment 427 | """ 428 | 429 | def _render(self): 430 | self.after = self.render_newlines()[1:] 431 | 432 | def _post_render(self): 433 | pass 434 | 435 | 436 | class VariableNode(LineNode): 437 | """ 438 | A Django variable node, e.g. =person.name 439 | """ 440 | 441 | def __init__(self, haml, indent, compiler): 442 | super(VariableNode, self).__init__(haml, indent, compiler) 443 | 444 | def _render(self): 445 | tag_content = self.haml.lstrip(VARIABLE_PREFIX) 446 | self.before = "%s{{ %s }}" % (self.indent, tag_content.strip()) 447 | self.after = self.render_newlines() 448 | 449 | def _post_render(self): 450 | pass 451 | 452 | 453 | class TagNode(LineNode): 454 | """ 455 | A Django/Jinja server-side tag node, e.g. -block 456 | """ 457 | 458 | def __init__(self, haml, indent, compiler): 459 | super(TagNode, self).__init__(haml, indent, compiler) 460 | 461 | self.tag_statement = self.haml.lstrip(TAG_PREFIX).strip() 462 | self.tag_name = self.tag_statement.split(" ")[0] 463 | 464 | if self.tag_name in self.compiler.self_closing_tags.values(): 465 | raise ParseException("Unexpected closing tag for self-closing tag %s" % self.tag_name) 466 | 467 | def _render(self): 468 | self.before = "%s{%% %s %%}" % (self.indent, self.tag_statement) 469 | 470 | closing_tag = self.compiler.self_closing_tags.get(self.tag_name) 471 | 472 | if closing_tag: 473 | if self.tag_name == "block" and self.compiler.options.endblock_names: 474 | block_name = self.tag_statement.split(" ")[1] 475 | closing_tag += " " + block_name 476 | 477 | self.before += self.render_newlines() 478 | self.after = "%s{%% %s %%}%s" % (self.indent, closing_tag, self.render_newlines()) 479 | else: 480 | if self.children: 481 | self.before += self.render_newlines() 482 | else: 483 | self.after = self.render_newlines() 484 | self._render_children() 485 | 486 | def should_contain(self, node): 487 | return isinstance(node, TagNode) and node.tag_name in self.compiler.tags_may_contain.get(self.tag_name, "") 488 | 489 | 490 | class FilterNode(Node): 491 | """ 492 | A type filter, e.g. :javascript 493 | """ 494 | 495 | def __init__(self, filter_name, content, indent, compiler): 496 | super(FilterNode, self).__init__(indent, compiler) 497 | 498 | self.filter_name = filter_name 499 | self.content = content 500 | 501 | def _render(self): 502 | content = textwrap.dedent(self.content) 503 | 504 | filter_func = get_filter(self.filter_name) 505 | content = filter_func(content, self.compiler.options) 506 | 507 | content = self.indent + content.replace("\n", "\n" + self.indent) 508 | 509 | self.before = content 510 | self.after = self.render_newlines() if self.content else "" 511 | 512 | def _post_render(self): 513 | pass 514 | 515 | def __repr__(self): # pragma: no cover 516 | return "%s(indent=%d, newlines=%d, filter=%s): %s" % ( 517 | type(self).__name__, 518 | self.indentation, 519 | self.newlines, 520 | self.filter_name, 521 | self.content, 522 | ) 523 | -------------------------------------------------------------------------------- /hamlpy/parser/utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from .core import Stream 4 | 5 | DJANGO_TAG_OPEN = "{%" 6 | DJANGO_TAG_CLOSE = "%}" 7 | DJANGO_EXP_OPEN = "{{" 8 | DJANGO_EXP_CLOSE = "}}" 9 | HTML_CHARS = {"&": "&", "<": "<", ">": ">", '"': """, "'": "'"} 10 | 11 | 12 | class EscapeState(Enum): 13 | normal = 0 14 | in_tag = 1 15 | in_exp = 2 16 | 17 | 18 | def html_escape(text): 19 | """ 20 | Escapes HTML entities, matching substitutions used by the Ruby Haml library. Entities that occur inside Django tags 21 | or expressions are not escaped. 22 | """ 23 | new_text = [] 24 | state = EscapeState.normal 25 | stream = Stream(text) 26 | 27 | while stream.ptr < stream.length: 28 | ch = stream.text[stream.ptr] 29 | ch2 = stream.text[stream.ptr : stream.ptr + 2] 30 | 31 | if ch2 == DJANGO_TAG_OPEN or ch2 == DJANGO_EXP_OPEN: 32 | state = EscapeState.in_tag if ch2 == DJANGO_TAG_OPEN else EscapeState.in_exp 33 | stream.ptr += 2 34 | new_text.append(ch2) 35 | elif (state == EscapeState.in_tag and ch2 == DJANGO_TAG_CLOSE) or ( 36 | state == EscapeState.in_exp and ch2 == DJANGO_EXP_CLOSE 37 | ): 38 | state = EscapeState.normal 39 | stream.ptr += 2 40 | new_text.append(ch2) 41 | else: 42 | stream.ptr += 1 43 | new_text.append(HTML_CHARS.get(ch, ch) if state == EscapeState.normal else ch) 44 | 45 | return "".join(new_text) 46 | -------------------------------------------------------------------------------- /hamlpy/template/__init__.py: -------------------------------------------------------------------------------- 1 | from .loaders import haml_loaders as _loaders 2 | 3 | locals().update(_loaders) 4 | -------------------------------------------------------------------------------- /hamlpy/template/loaders.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.template import TemplateDoesNotExist 5 | from django.template.loaders import app_directories, filesystem 6 | 7 | from hamlpy import HAML_EXTENSIONS 8 | from hamlpy.compiler import Compiler 9 | from hamlpy.template.utils import get_django_template_loaders 10 | 11 | # Get options from Django settings 12 | options = {} 13 | 14 | if hasattr(settings, "HAMLPY_ATTR_WRAPPER"): 15 | options.update(attr_wrapper=settings.HAMLPY_ATTR_WRAPPER) 16 | 17 | if hasattr(settings, "HAMLPY_DJANGO_INLINE_STYLE"): 18 | options.update(django_inline_style=settings.HAMLPY_DJANGO_INLINE_STYLE) 19 | 20 | 21 | def get_haml_loader(loader): 22 | class Loader(loader.Loader): 23 | def get_contents(self, origin): 24 | # Django>=1.9 25 | contents = super(Loader, self).get_contents(origin) 26 | name, _extension = os.path.splitext(origin.template_name) 27 | # os.path.splitext always returns a period at the start of extension 28 | extension = _extension.lstrip(".") 29 | 30 | if extension in HAML_EXTENSIONS: 31 | compiler = Compiler(options=options) 32 | return compiler.process(contents) 33 | 34 | return contents 35 | 36 | def load_template_source(self, template_name, *args, **kwargs): 37 | # Django<1.9 38 | name, _extension = os.path.splitext(template_name) 39 | # os.path.splitext always returns a period at the start of extension 40 | extension = _extension.lstrip(".") 41 | 42 | if extension in HAML_EXTENSIONS: 43 | try: 44 | haml_source, template_path = super(Loader, self).load_template_source( 45 | self._generate_template_name(name, extension), *args, **kwargs 46 | ) 47 | except TemplateDoesNotExist: # pragma: no cover 48 | pass 49 | else: 50 | compiler = Compiler(options=options) 51 | html = compiler.process(haml_source) 52 | 53 | return html, template_path 54 | 55 | raise TemplateDoesNotExist(template_name) 56 | 57 | load_template_source.is_usable = True 58 | 59 | @staticmethod 60 | def _generate_template_name(name, extension="hamlpy"): 61 | return "%s.%s" % (name, extension) 62 | 63 | return Loader 64 | 65 | 66 | haml_loaders = dict((name, get_haml_loader(loader)) for (name, loader) in get_django_template_loaders()) 67 | 68 | HamlPyFilesystemLoader = get_haml_loader(filesystem) 69 | HamlPyAppDirectoriesLoader = get_haml_loader(app_directories) 70 | -------------------------------------------------------------------------------- /hamlpy/template/templatize.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module decorates the django templatize function to parse haml templates before the translation utility extracts 3 | tags from it. 4 | """ 5 | 6 | import os 7 | 8 | from django.utils import translation 9 | 10 | from hamlpy import HAML_EXTENSIONS 11 | from hamlpy.compiler import Compiler 12 | 13 | 14 | def patch_templatize(func): 15 | def templatize(src, origin=None, charset="utf-8"): 16 | # if the template has no origin then don't attempt to convert it because we don't know if it's Haml 17 | if origin: 18 | extension = os.path.splitext(origin)[1][1:].lower() 19 | 20 | if extension in HAML_EXTENSIONS: 21 | compiler = Compiler() 22 | src = compiler.process(src) 23 | 24 | return func(src, origin=origin) 25 | 26 | return templatize 27 | 28 | 29 | translation.templatize = patch_templatize(translation.templatize) 30 | -------------------------------------------------------------------------------- /hamlpy/template/utils.py: -------------------------------------------------------------------------------- 1 | from importlib import machinery 2 | from os import listdir 3 | from os.path import dirname, splitext 4 | 5 | from django.template import loaders 6 | 7 | MODULE_EXTENSIONS = tuple(machinery.all_suffixes()) 8 | 9 | 10 | def get_django_template_loaders(): 11 | return [ 12 | (loader.__name__.rsplit(".", 1)[1], loader) for loader in get_submodules(loaders) if hasattr(loader, "Loader") 13 | ] 14 | 15 | 16 | def get_submodules(package): 17 | submodules = ("%s.%s" % (package.__name__, module) for module in package_contents(package)) 18 | return [__import__(module, {}, {}, [module.rsplit(".", 1)[-1]]) for module in submodules] 19 | 20 | 21 | def package_contents(package): 22 | package_path = dirname(loaders.__file__) 23 | contents = set([splitext(module)[0] for module in listdir(package_path) if module.endswith(MODULE_EXTENSIONS)]) 24 | return contents 25 | -------------------------------------------------------------------------------- /hamlpy/test/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | os.environ["DJANGO_SETTINGS_MODULE"] = "hamlpy.test.settings" 6 | django.setup() 7 | -------------------------------------------------------------------------------- /hamlpy/test/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | DATABASES = {} 4 | 5 | INSTALLED_APPS = ("hamlpy",) 6 | 7 | TEMPLATES = [ 8 | { 9 | "BACKEND": "django.template.backends.django.DjangoTemplates", 10 | "DIRS": ["hamlpy/test/templates"], 11 | "OPTIONS": { 12 | "loaders": [ 13 | "hamlpy.template.loaders.HamlPyFilesystemLoader", 14 | "hamlpy.template.loaders.HamlPyAppDirectoriesLoader", 15 | "django.template.loaders.filesystem.Loader", 16 | "django.template.loaders.app_directories.Loader", 17 | ], 18 | "debug": True, 19 | }, 20 | } 21 | ] 22 | 23 | SECRET_KEY = "tots top secret" 24 | -------------------------------------------------------------------------------- /hamlpy/test/templates/allIfTypesTest.hamlpy: -------------------------------------------------------------------------------- 1 | - if something 2 | Cool 3 | - ifchanged something 4 | Changed 5 | - ifequal something "booya" 6 | Equal 7 | - ifnotequal something "blamo" 8 | No Equal 9 | - ifchanged something 10 | Changed 11 | - else 12 | No Changed 13 | - ifequal something "booya" 14 | Equal 15 | - else 16 | No Equal 17 | - ifnotequal something "blamo" 18 | No Equal 19 | - else 20 | Equal -------------------------------------------------------------------------------- /hamlpy/test/templates/allIfTypesTest.html: -------------------------------------------------------------------------------- 1 | {% if something %} 2 | Cool 3 | {% endif %} 4 | {% ifchanged something %} 5 | Changed 6 | {% endifchanged %} 7 | {% ifequal something "booya" %} 8 | Equal 9 | {% endifequal %} 10 | {% ifnotequal something "blamo" %} 11 | No Equal 12 | {% endifnotequal %} 13 | {% ifchanged something %} 14 | Changed 15 | {% else %} 16 | No Changed 17 | {% endifchanged %} 18 | {% ifequal something "booya" %} 19 | Equal 20 | {% else %} 21 | No Equal 22 | {% endifequal %} 23 | {% ifnotequal something "blamo" %} 24 | No Equal 25 | {% else %} 26 | Equal 27 | {% endifnotequal %} 28 | -------------------------------------------------------------------------------- /hamlpy/test/templates/classIdMixtures.hamlpy: -------------------------------------------------------------------------------- 1 | %div#Article.article.entry.hover:no-underline{'id':'123', class:'true'} 2 | Now this is interesting 3 | %div.article.entry.hover:no-underline#Article(id='123' class='true') 4 | Now this is really interesting 5 | -------------------------------------------------------------------------------- /hamlpy/test/templates/classIdMixtures.html: -------------------------------------------------------------------------------- 1 |{{ story.tease|truncatewords:"100" }}
17 | {% endfor %} 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /hamlpy/test/templates/filterMultilineIgnore.hamlpy: -------------------------------------------------------------------------------- 1 | .multilinetest1{id:'{{myId}}', 2 | alt: "{{nothing}}"} 3 | :plain 4 | These { { braces 5 | 6 | should } not be interpreted as a multiline string 7 | :preserve 8 | These { { braces 9 | 10 | should } not be interpreted as a multiline string 11 | :css 12 | .test { 13 | display: inline; 14 | } 15 | :javascript 16 | These { 17 | Braces should { 18 | also 19 | } be { ignored 20 | 21 | .multilinetest2{id:'{{myId}}', 22 | class:'{{myClass}}', 23 | alt: ""} 24 | / The following is from hjonathan, issue #67 25 | %head 26 | .blah 27 | :javascript 28 | $(document).ready(function(){ 29 | $("#form{{form.initial.id}}").submit(form_submit); 30 | //Double nesting 31 | $(function() { 32 | blahblahblah 33 | }); 34 | 35 | // Javascript comment 36 | }); 37 | :javascript 38 | $(document).ready(function(){ 39 | $("#form{{form.initial.id}}").submit(form_submit); 40 | // Javascript comment 41 | }); 42 | :css 43 | .someClass { 44 | width: 100px; 45 | } 46 | :cdata 47 | if (a < b && a < 0) 48 | { 49 | return 1; 50 | } 51 | -# Regression from Culebron (Github issue #90) 52 | :javascript 53 | ( 54 | { 55 | a 56 | } 57 | ); 58 | :javascript 59 | 60 | { 61 | a 62 | } 63 | ) 64 | -------------------------------------------------------------------------------- /hamlpy/test/templates/filterMultilineIgnore.html: -------------------------------------------------------------------------------- 1 | 2 | These { { braces 3 | 4 | should } not be interpreted as a multiline string 5 | These { { braces should } not be interpreted as a multiline string 6 | 13 | 21 | 22 | 23 | 24 |hello 2 | no paragraph
3 |line break
4 | follow
New paragraph
6 |code block
7 |
8 |
--------------------------------------------------------------------------------
/hamlpy/test/templates/filtersPygments.hamlpy:
--------------------------------------------------------------------------------
1 | :highlight
2 | print "hi"
3 |
4 | if x:
5 | print "y":
6 | else:
7 | print "z":
8 |
--------------------------------------------------------------------------------
/hamlpy/test/templates/filtersPygments.html:
--------------------------------------------------------------------------------
1 | print "hi"
2 |
3 | if x:
4 | print "y":
5 | else:
6 | print "z":
7 |
Foo
2 |{% if something %}
3 |
4 | Foo
5 |
6 | {% endif %}
{{ Foo }}
8 |
9 | Foo
10 |
12 | Foo
13 |
15 | {{ FooBar }}
16 |
18 | Foo
19 | Bar
20 |
22 | Foo
23 | Bar
24 |
26 |
30 |
32 |
37 |
39 | {{ foo }}
40 | bar
41 |
42 | bar
43 |
44 |
test1 46 | test2
47 |blah 48 | test3 49 | test4
50 |test5 51 | test6 52 | test7 53 | test8 54 | test9
55 | -------------------------------------------------------------------------------- /hamlpy/test/templates/nukeOuterWhiteSpace.hamlpy: -------------------------------------------------------------------------------- 1 | %ul#display-inline-block-example 2 | %li Item one 3 | %li> Item two 4 | %li Item three 5 | %p 6 | - if something 7 | %q> 8 | Foo 9 | %p 10 | - sometag 11 | %q> 12 | Foo 13 | %p 14 | /[test] 15 | %q> 16 | Foo 17 | %p 18 | :javascript 19 | test 20 | %q> 21 | blah 22 | -# Tests from Ruby HAML 23 | %p 24 | %p 25 | %q> 26 | Foo 27 | %p 28 | %p 29 | %q{a: '2'}> 30 | Foo 31 | %p 32 | %p 33 | %q> Foo 34 | %p 35 | %p 36 | %q{a: "2"}> Foo 37 | %p 38 | %p 39 | %q> 40 | = Foo 41 | %p 42 | %p 43 | %q{a: "2"}> 44 | = Foo 45 | %p 46 | %p 47 | %q>= Foo 48 | %p 49 | %p 50 | %q{a: "2"}>= Foo 51 | %p 52 | %p 53 | foo 54 | %q> 55 | Foo 56 | bar 57 | %p 58 | %p 59 | foo 60 | %q{a: 2}> 61 | Foo 62 | bar 63 | %p 64 | %p 65 | foo 66 | %q> Foo 67 | bar 68 | %p 69 | %p 70 | foo 71 | %q{a: "2"}> Foo 72 | bar 73 | %p 74 | %p 75 | foo 76 | %q> 77 | = Foo 78 | bar 79 | %p 80 | %p 81 | foo 82 | %q{a: "2"}> 83 | = Foo 84 | bar 85 | %p 86 | %p 87 | foo 88 | %q>= Foo 89 | bar 90 | %p 91 | %p 92 | foo 93 | %q{a: "2"}>= Foo 94 | bar 95 | %p 96 | %p 97 | foo 98 | %q> 99 | = FooBar 100 | bar 101 | %p 102 | %p 103 | foo 104 | %q{a: "2"}> 105 | = FooBar 106 | bar 107 | %p 108 | %p 109 | foo 110 | %q>= FooBar 111 | bar 112 | %p 113 | %p 114 | foo 115 | %q{a: "2"}>= FooBar 116 | bar 117 | %p 118 | %p 119 | %q> 120 | %p 121 | %p 122 | %q>/ 123 | %p 124 | %p 125 | %q{a: "2"}> 126 | %p 127 | %p 128 | %q{a: "2"}>/ -------------------------------------------------------------------------------- /hamlpy/test/templates/nukeOuterWhiteSpace.html: -------------------------------------------------------------------------------- 1 |
5 | {% if something %}
6 | Foo
7 |
{% endif %}
8 |
10 | {% sometag %}
11 | Foo
12 |
14 | 17 |
18 |
19 |
24 | blah
25 |
27 |
28 | Foo
29 |
32 |
33 | Foo
34 |
37 |
Foo
40 |
Foo
43 |
44 | {{ Foo }}
45 |
48 |
49 | {{ Foo }}
50 |
53 |
{{ Foo }}
56 |
{{ Foo }}
59 |
60 | foo
61 | Foo
62 |
bar
63 |
66 |
67 | foo
68 | Foo
69 |
bar
70 |
73 |
74 | fooFoo
bar
75 |
78 |
79 | fooFoo
bar
80 |
83 |
84 | foo
85 | {{ Foo }}
86 |
bar
87 |
90 |
91 | foo
92 | {{ Foo }}
93 |
bar
94 |
97 |
98 | foo{{ Foo }}
bar
99 |
102 |
103 | foo{{ Foo }}
bar
104 |
107 |
108 | foo
109 | {{ FooBar }}
110 |
bar
111 |
114 |
115 | foo
116 | {{ FooBar }}
117 |
bar
118 |
121 |
122 | foo{{ FooBar }}
bar
123 |
126 |
127 | foo{{ FooBar }}
bar
128 |
131 |
132 | 133 |
134 |
135 | 136 |
137 |
138 | 139 |
140 |
141 | 142 | -------------------------------------------------------------------------------- /hamlpy/test/templates/selfClosingDjango.hamlpy: -------------------------------------------------------------------------------- 1 | %ul 2 | - for story in story_list 3 | %li= story.text 4 | - blocktrans with object.duration_as_time as dur 5 | %span.jtimer.bigger {{ dur }} 6 | remain 7 | -------------------------------------------------------------------------------- /hamlpy/test/templates/selfClosingDjango.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |17 | 18 | 19 | 20 |5 | 6 | 7 | Some text 8 | 9 | Some other text 10 | 11 | 12 | Some more text 13 | 14 | 15 |16 |
109 | 110 |
hello
\n{% else %}\ngoodbye
\n{% endif %}", 139 | ) 140 | 141 | # with 142 | self._test("- with thing1 as another\n stuff", "{% with thing1 as another %}\n stuff\n{% endwith %}") 143 | self._test( 144 | "- with context\n hello\n- with other_context\n goodbye", 145 | "{% with context %}\n hello\n{% endwith %}\n{% with other_context %}\n goodbye\n{% endwith %}", 146 | ) 147 | 148 | # block 149 | self._test( 150 | "-block title\n %p hello\n", 151 | "{% block title %}\nhello
\n{% endblock %}", 152 | ) 153 | self._test( 154 | "-block title\n %p hello\n", 155 | "{% block title %}\nhello
\n{% endblock title %}", 156 | options={"endblock_names": True}, 157 | ) 158 | 159 | # trans 160 | self._test('- trans "Hello"\n', '{% trans "Hello" %}') 161 | 162 | # blocktrans 163 | self._test( 164 | "- blocktrans with amount=num_cookies\n" " There are #{ amount } cookies", 165 | "{% blocktrans with amount=num_cookies %}\n" " There are {{ amount }} cookies\n" "{% endblocktrans %}", 166 | ) 167 | self._test( 168 | "- blocktrans with amount=num_cookies\n" 169 | " There is one cookie\n" 170 | "- plural\n" 171 | " There are #{ amount } cookies", 172 | "{% blocktrans with amount=num_cookies %}\n" 173 | " There is one cookie\n" 174 | "{% plural %}\n" 175 | " There are {{ amount }} cookies\n" 176 | "{% endblocktrans %}", 177 | ) 178 | 179 | # exception using a closing tag of a self-closing tag 180 | parser = Compiler() 181 | self.assertRaises(ParseException, parser.process, "- endfor") 182 | 183 | def test_plain_text(self): 184 | self._test("This should be plain text", "This should be plain text") 185 | self._test( 186 | "This should be plain text\n This should be indented", 187 | "This should be plain text\n This should be indented", 188 | ) 189 | 190 | # native Django tags {% %} should be treated as plain text 191 | self._test("text {%\n trans ''\n%}", "text {%\n trans ''\n%}") 192 | self._test("text\n {%\n trans ''\n%}", "text\n {%\n trans ''\n%}") 193 | 194 | def test_plain_filter(self): 195 | # with indentation 196 | self._test( 197 | ":plain\n -This should be plain text\n .This should be more\n This should be indented", 198 | "-This should be plain text\n.This should be more\n This should be indented", 199 | ) 200 | 201 | # with no children 202 | self._test(":plain\nNothing", "Nothing") 203 | 204 | # with escaped back slash 205 | self._test(":plain\n \\Something", "\\Something") 206 | 207 | # with space after filter name 208 | self._test(":plain \n -This should be plain text\n", "-This should be plain text") 209 | 210 | def test_preserve_filter(self): 211 | # with indentation 212 | self._test( 213 | ":preserve\n -This should be plain text\n .This should be more\n This should be indented", 214 | "-This should be plain text .This should be more This should be indented", 215 | ) 216 | 217 | # with no children 218 | self._test(":preserve\nNothing", "Nothing") 219 | 220 | # with escaped back slash 221 | self._test(":preserve\n \\Something", "\\Something") 222 | 223 | def test_python_filter(self): 224 | self._test(":python\n", "") # empty 225 | self._test( 226 | ':python\n for i in range(0, 5): print("item \\%s
" % i)', 227 | "item \\0
\nitem \\1
\nitem \\2
\nitem \\3
\nitem \\4
", 228 | ) 229 | 230 | self._test_error(":python\n print(10 / 0)", "Error whilst executing python filter node", ZeroDivisionError) 231 | 232 | def test_pygments_filter(self): 233 | self._test(":highlight\n", "") # empty 234 | self._test( 235 | ":highlight\n print(1)\n", 'print(1)\n
Title
") 247 | 248 | filters._markdown_available = False 249 | 250 | self._test_error(":markdown\n *Title*\n", "Markdown is not available") 251 | 252 | filters._markdown_available = True 253 | 254 | def test_invalid_filter(self): 255 | self._test_error(":nosuchfilter\n", "No such filter: nosuchfilter") 256 | 257 | def test_doctypes(self): 258 | self._test("!!!", "", options={"format": "html5"}) 259 | self._test("!!! 5", "", options={"format": "xhtml"}) 260 | self._test("!!! 5", "") 261 | self._test( 262 | "!!! strict", 263 | '', # noqa 264 | options={"format": "xhtml"}, 265 | ) 266 | self._test( 267 | "!!! frameset", 268 | '', # noqa 269 | options={"format": "xhtml"}, 270 | ) 271 | self._test( 272 | "!!! mobile", 273 | '', # noqa 274 | options={"format": "xhtml"}, 275 | ) 276 | self._test( 277 | "!!! basic", 278 | '', # noqa 279 | options={"format": "xhtml"}, 280 | ) 281 | self._test( 282 | "!!! transitional", 283 | '', # noqa 284 | options={"format": "xhtml"}, 285 | ) 286 | self._test( 287 | "!!!", 288 | '', # noqa 289 | options={"format": "xhtml"}, 290 | ) 291 | self._test( 292 | "!!! strict", 293 | '', # noqa 294 | options={"format": "html4"}, 295 | ) 296 | self._test( 297 | "!!! frameset", 298 | '', # noqa 299 | options={"format": "html4"}, 300 | ) 301 | self._test( 302 | "!!! transitional", 303 | '', # noqa 304 | options={"format": "html4"}, 305 | ) 306 | self._test( 307 | "!!!", 308 | '', # noqa 309 | options={"format": "html4"}, 310 | ) 311 | 312 | self._test("!!! XML", "", options={"format": "html4"}) 313 | self._test("!!! XML iso-8589", "", options={"format": "xhtml"}) 314 | 315 | def test_attr_wrapper(self): 316 | self._test( 317 | "%p{ :strange => 'attrs'}", "", options={"attr_wrapper": "*", "escape_attrs": True} 318 | ) 319 | self._test( 320 | "%p{ :escaped => 'quo\"te'}", 321 | '', 322 | options={"attr_wrapper": '"', "escape_attrs": True}, 323 | ) 324 | self._test( 325 | "%p{ :escaped => 'quo\\'te'}", 326 | '', 327 | options={"attr_wrapper": '"', "escape_attrs": True}, 328 | ) 329 | self._test( 330 | "%p{ :escaped => 'q\\'uo\"te'}", 331 | '', 332 | options={"attr_wrapper": '"', "escape_attrs": True}, 333 | ) 334 | self._test( 335 | "!!! XML", 336 | '', 337 | options={"attr_wrapper": '"', "format": "xhtml", "escape_attrs": True}, 338 | ) 339 | 340 | # smart quotes... 341 | 342 | self._test( 343 | """%a(onclick="alert('hi')")""", 344 | """""", 345 | options={"attr_wrapper": '"', "smart_quotes": False}, 346 | ) 347 | self._test( 348 | """%a(onclick="alert('hi')")""", 349 | """""", 350 | options={"attr_wrapper": '"', "smart_quotes": True}, 351 | ) 352 | self._test( 353 | """%a(onclick="alert('hi')")""", 354 | """""", 355 | options={"attr_wrapper": "'", "smart_quotes": True}, 356 | ) 357 | self._test( 358 | """%a(onclick='alert("hi {% trans "world" %}")')""", 359 | """""", 360 | options={"attr_wrapper": '"', "smart_quotes": True}, 361 | ) 362 | 363 | def test_attr_escaping(self): 364 | self._test( 365 | """#foo{:class => ''}""", 366 | """""", 367 | options={"escape_attrs": False}, 368 | ) 369 | self._test( 370 | """#foo{:class => '"<>&"'}""", 371 | """""", 372 | options={"escape_attrs": True}, 373 | ) 374 | self._test( 375 | """#foo{:class => '{% trans "Hello" %}'}""", 376 | """""", 377 | options={"escape_attrs": True}, 378 | ) 379 | 380 | def test_node_escaping(self): 381 | self._test("\\= Escaped", "= Escaped") 382 | self._test("\\%}", "%}") 383 | self._test(" \\:python", " :python") 384 | 385 | def test_utf8(self): 386 | self._test( 387 | "%a{'href':'', 'title':'링크(Korean)'} Some Link", "Some Link" 388 | ) 389 | 390 | def test_custom_filter(self): 391 | def upper(text, options): 392 | return text.upper() 393 | 394 | filters.register_filter("upper", upper) 395 | 396 | self._test(":upper\n welcome", "WELCOME") 397 | 398 | def _test(self, haml, expected_html, options=None): 399 | if not options: 400 | options = {"escape_attrs": True} 401 | 402 | compiler = Compiler(options) 403 | result = compiler.process(haml) 404 | 405 | result = result.rstrip("\n") # ignore trailing new lines 406 | 407 | assert result == expected_html 408 | 409 | def _test_error(self, haml, expected_message, expected_cause=None, compiler_options=None): 410 | compiler = Compiler(compiler_options) 411 | 412 | try: 413 | compiler.process(haml) 414 | self.fail("Expected exception to be raised") 415 | except Exception as e: 416 | self.assertIsInstance(e, ParseException) 417 | assert str(e) == expected_message 418 | 419 | if expected_cause: 420 | assert type(e.__cause__) == expected_cause 421 | -------------------------------------------------------------------------------- /hamlpy/test/test_elements.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from collections import OrderedDict 3 | 4 | from hamlpy.compiler import Compiler 5 | from hamlpy.parser.core import Stream 6 | from hamlpy.parser.elements import Element, read_element, read_tag 7 | 8 | 9 | class ElementTest(unittest.TestCase): 10 | def test_read_tag(self): 11 | stream = Stream("angular:ng-repeat(") 12 | self.assertEqual(read_tag(stream), "angular:ng-repeat") 13 | self.assertEqual(stream.text[stream.ptr :], "(") 14 | 15 | def test_read_element(self): 16 | stream = Stream('%angular:ng-repeat.my-class.other:class#my-id(class="test")>= Hello \n.next-element') 17 | element = read_element(stream, Compiler()) 18 | self.assertEqual(element.tag, "angular:ng-repeat") 19 | self.assertEqual(element.id, "my-id") 20 | self.assertEqual(element.classes, ["test", "my-class", "other:class"]) 21 | self.assertEqual(dict(element.attributes), {"class": "test"}) 22 | self.assertEqual(element.nuke_outer_whitespace, True) 23 | self.assertEqual(element.nuke_inner_whitespace, True) 24 | self.assertEqual(element.self_close, True) 25 | self.assertEqual(element.django_variable, True) 26 | self.assertEqual(element.inline_content, "Hello") 27 | self.assertEqual(stream.text[stream.ptr :], ".next-element") 28 | 29 | stream = Stream("%input{required} ") 30 | element = read_element(stream, Compiler()) 31 | self.assertEqual(element.tag, "input") 32 | self.assertEqual(element.id, None) 33 | self.assertEqual(element.classes, []) 34 | self.assertEqual(dict(element.attributes), {"required": True}) 35 | self.assertEqual(element.nuke_outer_whitespace, False) 36 | self.assertEqual(element.nuke_inner_whitespace, False) 37 | self.assertEqual(element.self_close, True) # input is implicitly self-closing 38 | self.assertEqual(element.django_variable, False) 39 | self.assertEqual(element.inline_content, "") 40 | self.assertEqual(stream.text[stream.ptr :], "") 41 | 42 | def test_escape_attribute_quotes(self): 43 | s1 = Element._escape_attribute_quotes("""{% url 'blah' %}""", attr_wrapper="'") 44 | assert s1 == """{% url 'blah' %}""" 45 | 46 | s2 = Element._escape_attribute_quotes( 47 | """'foo'="bar" {% url 'blah' "blah" %} blah's blah''s""", attr_wrapper="'" 48 | ) 49 | assert s2 == """'foo'="bar" {% url 'blah' "blah" %} blah's blah''s""" 50 | 51 | s3 = Element._escape_attribute_quotes("""'foo'="bar" {% url 'blah' "blah" %}""", attr_wrapper='"') 52 | assert s3 == """'foo'="bar" {% url 'blah' "blah" %}""" 53 | 54 | def test_parses_tag(self): 55 | element = self._read_element("%span.class") 56 | assert element.tag == "span" 57 | 58 | # can have namespace and hypens 59 | element = self._read_element("%ng-tags:ng-repeat.class") 60 | assert element.tag == "ng-tags:ng-repeat" 61 | 62 | # defaults to div 63 | element = self._read_element(".class#id") 64 | assert element.tag == "div" 65 | 66 | def test_parses_id(self): 67 | element = self._read_element("%div#someId.someClass") 68 | assert element.id == "someId" 69 | 70 | element = self._read_element("#someId.someClass") 71 | assert element.id == "someId" 72 | 73 | element = self._read_element("%div.someClass") 74 | assert element.id is None 75 | 76 | def test_parses_classes(self): 77 | element = self._read_element("%div#someId.someClass") 78 | assert element.classes == ["someClass"] 79 | 80 | element = self._read_element("%div#someId.someClass.anotherClass") 81 | assert element.classes == ["someClass", "anotherClass"] 82 | 83 | # classes before id 84 | element = self._read_element("%div.someClass.anotherClass#someId") 85 | assert element.classes == ["someClass", "anotherClass"] 86 | 87 | # classes can contain hypens, underscores and colons 88 | element = self._read_element("%div.-some-class-._another_class_.also:class") 89 | assert element.classes == ["-some-class-", "_another_class_", "also:class"] 90 | 91 | # no class 92 | element = self._read_element("%div#someId") 93 | assert element.classes == [] 94 | 95 | def test_attribute_dictionary_properly_parses(self): 96 | element = self._read_element("%html{'xmlns':'http://www.w3.org/1999/xhtml', 'xml:lang':'en', 'lang':'en'}") 97 | 98 | assert element.attributes == OrderedDict( 99 | [("xmlns", "http://www.w3.org/1999/xhtml"), ("xml:lang", "en"), ("lang", "en")] 100 | ) 101 | 102 | def test_attribute_merges_classes_properly(self): 103 | element = self._read_element("%div.someClass.anotherClass{'class':'hello'}") 104 | 105 | assert element.classes == ["hello", "someClass", "anotherClass"] 106 | 107 | def test_attribute_merges_ids_properly(self): 108 | element = self._read_element("%div#someId{'id':'hello'}") 109 | assert element.id == "someId_hello" 110 | 111 | def test_can_use_arrays_for_id_in_attributes(self): 112 | element = self._read_element("%div#someId{'id':['more', 'andMore']}") 113 | assert element.id == "someId_more_andMore" 114 | 115 | def test_self_closes_a_self_closing_tag(self): 116 | element = self._read_element(r"%br") 117 | assert element.self_close 118 | 119 | def test_does_not_close_a_non_self_closing_tag(self): 120 | element = self._read_element("%div") 121 | assert element.self_close is False 122 | 123 | def test_can_close_a_non_self_closing_tag(self): 124 | element = self._read_element("%div/") 125 | assert element.self_close 126 | 127 | def test_properly_detects_django_tag(self): 128 | element = self._read_element("%div= $someVariable") 129 | assert element.django_variable 130 | 131 | def test_knows_when_its_not_django_tag(self): 132 | element = self._read_element("%div Some Text") 133 | assert element.django_variable is False 134 | 135 | def test_grabs_inline_tag_content(self): 136 | element = self._read_element("%div Some Text") 137 | assert element.inline_content == "Some Text" 138 | 139 | element = self._read_element("%div {% trans 'hello' %}") 140 | assert element.inline_content == "{% trans 'hello' %}" 141 | 142 | def test_multiline_attributes(self): 143 | element = self._read_element( 144 | """%link{'rel': 'stylesheet', 'type': 'text/css', 145 | 'href': '/long/url/to/stylesheet/resource.css'}""" 146 | ) 147 | 148 | assert element.attributes == OrderedDict( 149 | [("rel", "stylesheet"), ("type", "text/css"), ("href", "/long/url/to/stylesheet/resource.css")] 150 | ) 151 | 152 | @staticmethod 153 | def _read_element(text): 154 | return read_element(Stream(text), Compiler()) 155 | -------------------------------------------------------------------------------- /hamlpy/test/test_jinja.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from jinja2.exceptions import TemplateSyntaxError 4 | 5 | from hamlpy.jinja import HamlPyExtension 6 | 7 | 8 | class JinjaTest(unittest.TestCase): 9 | def test_preprocess(self): 10 | extension = HamlPyExtension(None) 11 | 12 | assert extension.preprocess("%span Hello", "test.haml") == "Hello\n" 13 | assert extension.preprocess("%span Hello", "test.hamlpy") == "Hello\n" 14 | assert extension.preprocess("%span Hello", "../templates/test.hamlpy") == "Hello\n" 15 | 16 | # non-Haml extension should be ignored 17 | assert extension.preprocess("%span Hello", "test.txt") == "%span Hello" 18 | 19 | # exceptions converted to Jinja2 exceptions 20 | self.assertRaisesRegex( 21 | TemplateSyntaxError, 22 | r'Unterminated string \(expected "\). @ "%span{"}"', 23 | extension.preprocess, 24 | '%span{"}', 25 | "test.haml", 26 | ) 27 | -------------------------------------------------------------------------------- /hamlpy/test/test_loader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from django.template import TemplateDoesNotExist 7 | from django.template.loader import render_to_string 8 | from django.test import SimpleTestCase 9 | from django.test.utils import override_settings 10 | 11 | from hamlpy.compiler import Compiler 12 | from hamlpy.template import loaders 13 | 14 | 15 | class LoaderTest(SimpleTestCase): 16 | def setUp(self): 17 | super(LoaderTest, self).setUp() 18 | 19 | importlib.reload(loaders) 20 | 21 | @mock.patch("hamlpy.template.loaders.Compiler", wraps=Compiler) 22 | def test_compiler_default_settings(self, mock_compiler_class): 23 | render_to_string("simple.hamlpy") 24 | 25 | mock_compiler_class.assert_called_once_with(options={}) 26 | mock_compiler_class.reset_mock() 27 | 28 | @override_settings(HAMLPY_ATTR_WRAPPER='"', HAMLPY_DJANGO_INLINE_STYLE=False) 29 | def test_compiler_settings(self): 30 | importlib.reload(loaders) 31 | 32 | with mock.patch("hamlpy.template.loaders.Compiler", wraps=Compiler) as mock_compiler_class: 33 | rendered = render_to_string("simple.hamlpy") 34 | 35 | mock_compiler_class.assert_called_once_with(options={"attr_wrapper": '"', "django_inline_style": False}) 36 | 37 | assert '"someClass"' in rendered 38 | 39 | def test_template_rendering(self): 40 | assert render_to_string("simple.hamlpy") == self._load_test_template("simple.html") 41 | 42 | context = { 43 | "section": {"title": "News", "subtitle": "Technology"}, 44 | "story_list": [ 45 | { 46 | "headline": "Haml Helps", 47 | "tease": "Many HAML users...", 48 | "get_absolute_url": lambda: "http://example.com/stories/1/", 49 | } 50 | ], 51 | } 52 | 53 | rendered = render_to_string("djangoCombo.hamlpy", context) 54 | 55 | assert "Many HAML users...
" 59 | 60 | def test_should_ignore_non_haml_templates(self): 61 | assert render_to_string("simple.html") == self._load_test_template("simple.html") 62 | 63 | def test_should_raise_exception_when_template_doesnt_exist(self): 64 | with pytest.raises(TemplateDoesNotExist): 65 | render_to_string("simple.xyz") 66 | 67 | def _load_test_template(self, name): 68 | return open("hamlpy/test/templates/" + name, "r").read() 69 | -------------------------------------------------------------------------------- /hamlpy/test/test_nodes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from hamlpy.compiler import Compiler 4 | from hamlpy.parser import nodes 5 | from hamlpy.parser.core import Stream 6 | from hamlpy.parser.nodes import read_filter_node, read_node 7 | 8 | 9 | class NodeTest(unittest.TestCase): 10 | def test_read_node(self): 11 | assert isinstance(self._read_node("%div"), nodes.ElementNode) 12 | assert isinstance(self._read_node(" %html"), nodes.ElementNode) 13 | assert isinstance(self._read_node(".className"), nodes.ElementNode) 14 | assert isinstance(self._read_node(" .className"), nodes.ElementNode) 15 | assert isinstance(self._read_node("#idName"), nodes.ElementNode) 16 | assert isinstance(self._read_node(" #idName"), nodes.ElementNode) 17 | assert isinstance(self._read_node("/ some Comment"), nodes.CommentNode) 18 | assert isinstance(self._read_node(" / some Comment"), nodes.CommentNode) 19 | assert isinstance(self._read_node("just some random text"), nodes.PlaintextNode) 20 | assert isinstance(self._read_node(" more random text"), nodes.PlaintextNode) 21 | assert isinstance(self._read_node("-# This is a haml comment"), nodes.HamlCommentNode) 22 | assert isinstance(self._read_node("= some.variable"), nodes.VariableNode) 23 | assert isinstance(self._read_node("- for something in somethings"), nodes.TagNode) 24 | assert isinstance(self._read_node("\\= some.variable"), nodes.PlaintextNode) 25 | assert isinstance(self._read_node(" \\= some.variable"), nodes.PlaintextNode) 26 | assert isinstance(self._read_node("/[if IE 5]"), nodes.ConditionalCommentNode) 27 | assert isinstance(self._read_node(":plain"), nodes.FilterNode) 28 | assert isinstance(self._read_node(" :css\n"), nodes.FilterNode) 29 | assert isinstance(self._read_node(":stylus"), nodes.FilterNode) 30 | assert isinstance(self._read_node(":javascript"), nodes.FilterNode) 31 | assert isinstance(self._read_node(":coffee"), nodes.FilterNode) 32 | 33 | def test_read_filter_node(self): 34 | stream = Stream(':python\n print("hello")\n') 35 | node = read_filter_node(stream, "", Compiler()) 36 | assert node.filter_name == "python" 37 | assert node.content == ' print("hello")' 38 | assert stream.text[stream.ptr :] == "" 39 | 40 | stream = Stream(":javascript\n var i = 0;\n var j = 1;\n%span") 41 | node = read_filter_node(stream, "", Compiler()) 42 | assert node.filter_name == "javascript" 43 | assert node.content == " var i = 0;\n var j = 1;" 44 | assert stream.text[stream.ptr :] == "%span" 45 | 46 | def test_calculates_indentation_properly(self): 47 | no_indentation = self._read_node("%div") 48 | assert no_indentation.indentation == 0 49 | 50 | three_indentation = self._read_node(" %div") 51 | assert three_indentation.indentation == 3 52 | 53 | six_indentation = self._read_node(" %div") 54 | assert six_indentation.indentation == 6 55 | 56 | def test_indents_tabs_properly(self): 57 | no_indentation = self._read_node("%div") 58 | assert no_indentation.indent == "" 59 | 60 | one_tab = self._read_node(" %div") 61 | assert one_tab.indent == "\t" 62 | 63 | one_space = self._read_node(" %div") 64 | assert one_space.indent == " " 65 | 66 | three_tabs = self._read_node(" %div") 67 | assert three_tabs.indent == "\t\t\t" 68 | 69 | tab_space = self._read_node(" %div") 70 | assert tab_space.indent == "\t\t" 71 | 72 | space_tab = self._read_node(" %div") 73 | assert space_tab.indent == " " 74 | 75 | def test_lines_are_always_stripped_of_whitespace(self): 76 | some_space = self._read_node(" text") 77 | assert some_space.haml == "text" 78 | 79 | lots_of_space = self._read_node(" text ") 80 | assert lots_of_space.haml == "text" 81 | 82 | def test_inserts_nodes_into_proper_tree_depth(self): 83 | no_indentation_node = self._read_node("%div") 84 | one_indentation_node = self._read_node(" %div") 85 | two_indentation_node = self._read_node(" %div") 86 | another_one_indentation_node = self._read_node(" %div") 87 | 88 | no_indentation_node.add_node(one_indentation_node) 89 | no_indentation_node.add_node(two_indentation_node) 90 | no_indentation_node.add_node(another_one_indentation_node) 91 | 92 | assert one_indentation_node == no_indentation_node.children[0] 93 | assert two_indentation_node == no_indentation_node.children[0].children[0] 94 | assert another_one_indentation_node == no_indentation_node.children[1] 95 | 96 | def test_adds_multiple_nodes_to_one(self): 97 | start = self._read_node("%div") 98 | one = self._read_node(" %div") 99 | two = self._read_node(" %div") 100 | three = self._read_node(" %div") 101 | 102 | start.add_node(one) 103 | start.add_node(two) 104 | start.add_node(three) 105 | 106 | assert len(start.children) == 3 107 | 108 | @staticmethod 109 | def _read_node(haml): 110 | return read_node(Stream(haml), None, Compiler()) 111 | -------------------------------------------------------------------------------- /hamlpy/test/test_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from hamlpy.parser.core import ( 4 | ParseException, 5 | Stream, 6 | peek_indentation, 7 | read_line, 8 | read_number, 9 | read_quoted_string, 10 | read_symbol, 11 | read_whitespace, 12 | read_word, 13 | ) 14 | from hamlpy.parser.utils import html_escape 15 | 16 | 17 | class ParserTest(unittest.TestCase): 18 | def test_read_whitespace(self): 19 | stream = Stream(" \t foo \n bar ") 20 | 21 | assert read_whitespace(stream) == " \t " 22 | assert stream.text[stream.ptr :] == "foo \n bar " 23 | 24 | stream.ptr += 3 # skip over foo 25 | 26 | assert read_whitespace(stream) == " " 27 | assert stream.text[stream.ptr :] == "\n bar " 28 | 29 | assert read_whitespace(stream, include_newlines=True) == "\n " 30 | assert stream.text[stream.ptr :] == "bar " 31 | 32 | stream.ptr += 3 # skip over bar 33 | 34 | assert read_whitespace(stream) == " " 35 | assert stream.text[stream.ptr :] == "" 36 | 37 | def test_peek_indentation(self): 38 | assert peek_indentation(Stream("content")) == 0 39 | assert peek_indentation(Stream(" content")) == 2 40 | assert peek_indentation(Stream("\n")) is None 41 | assert peek_indentation(Stream(" \n")) is None 42 | 43 | def test_quoted_string(self): 44 | stream = Stream("'hello'---") 45 | assert read_quoted_string(stream) == "hello" 46 | assert stream.text[stream.ptr :] == "---" 47 | 48 | stream = Stream('"this don\'t \\"x\\" hmm" not in string') 49 | assert read_quoted_string(stream) == 'this don\'t "x" hmm' 50 | assert stream.text[stream.ptr :] == " not in string" 51 | 52 | self.assertRaises(ParseException, read_quoted_string, Stream('"no end quote...')) 53 | 54 | def test_read_line(self): 55 | stream = Stream("line1\n line2\n\nline4\n\n") 56 | assert read_line(stream) == "line1" 57 | assert read_line(stream) == " line2" 58 | assert read_line(stream) == "" 59 | assert read_line(stream) == "line4" 60 | assert read_line(stream) == "" 61 | assert read_line(stream) is None 62 | 63 | assert read_line(Stream("last line ")) == "last line " 64 | 65 | def test_read_number(self): 66 | stream = Stream('123"') 67 | assert read_number(stream) == "123" 68 | assert stream.text[stream.ptr :] == '"' 69 | 70 | stream = Stream("123.4xx") 71 | assert read_number(stream) == "123.4" 72 | assert stream.text[stream.ptr :] == "xx" 73 | 74 | stream = Stream("0.0001 ") 75 | assert read_number(stream) == "0.0001" 76 | assert stream.text[stream.ptr :] == " " 77 | 78 | def test_read_symbol(self): 79 | stream = Stream("=> bar") 80 | assert read_symbol(stream, ["=>", ":"]) == "=>" 81 | assert stream.text[stream.ptr :] == " bar" 82 | 83 | self.assertRaises(ParseException, read_symbol, Stream("foo"), ["=>"]) 84 | 85 | def test_read_word(self): 86 | stream = Stream("foo_bar") 87 | assert read_word(stream) == "foo_bar" 88 | assert stream.text[stream.ptr :] == "" 89 | 90 | stream = Stream("foo_bar ") 91 | assert read_word(stream) == "foo_bar" 92 | assert stream.text[stream.ptr :] == " " 93 | 94 | stream = Stream("ng-repeat(") 95 | assert read_word(stream) == "ng" 96 | assert stream.text[stream.ptr :] == "-repeat(" 97 | 98 | stream = Stream("ng-repeat(") 99 | assert read_word(stream, ("-",)) == "ng-repeat" 100 | assert stream.text[stream.ptr :] == "(" 101 | 102 | stream = Stream("これはテストです...") 103 | assert read_word(stream) == "これはテストです" 104 | assert stream.text[stream.ptr :] == "..." 105 | 106 | 107 | class UtilsTest(unittest.TestCase): 108 | def test_html_escape(self): 109 | assert html_escape("") == "" 110 | assert html_escape("&<>\"'") == "&<>"'" 111 | assert html_escape('{% trans "hello" %}') == '{% trans "hello" %}' 112 | assert html_escape('{{ foo|default:"hello" }}') == '{{ foo|default:"hello" }}' 113 | assert html_escape("{% }} & %}") == "{% }} & %}" 114 | 115 | result = html_escape('<>{% trans "hello" %}<>{{ foo|default:"hello" }}<>') 116 | assert result == '<>{% trans "hello" %}<>{{ foo|default:"hello" }}<>' 117 | -------------------------------------------------------------------------------- /hamlpy/test/test_templates.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import time 4 | import unittest 5 | from os import listdir, path 6 | 7 | import regex 8 | 9 | from hamlpy.compiler import Compiler 10 | 11 | TEMPLATE_DIRECTORY = "/templates/" 12 | TEMPLATE_EXTENSION = ".hamlpy" 13 | 14 | 15 | class TemplateCheck(object): 16 | compiler = Compiler(options={"format": "xhtml", "escape_attrs": True}) 17 | 18 | def __init__(self, name, haml, html): 19 | self.name = name 20 | self.haml = haml 21 | self.expected_html = html.replace("\r", "") # ignore line ending differences 22 | self.actual_html = None 23 | 24 | @classmethod 25 | def load_all(cls): 26 | directory = os.path.dirname(__file__) + TEMPLATE_DIRECTORY 27 | tests = [] 28 | 29 | # load all test files 30 | for f in listdir(directory): 31 | haml_path = path.join(directory, f) 32 | if haml_path.endswith(TEMPLATE_EXTENSION): 33 | html_path = path.splitext(haml_path)[0] + ".html" 34 | 35 | haml = codecs.open(haml_path, encoding="utf-8").read() 36 | html = open(html_path, "r").read() 37 | 38 | tests.append(TemplateCheck(haml_path, haml, html)) 39 | 40 | return tests 41 | 42 | def run(self): 43 | parsed = self.compiler.process(self.haml) 44 | 45 | # ignore line ending differences and blank lines 46 | self.actual_html = parsed.replace("\r", "") 47 | self.actual_html = regex.sub("\n[ \t]+(?=\n)", "\n", self.actual_html) 48 | 49 | def passed(self): 50 | return self.actual_html == self.expected_html 51 | 52 | 53 | class TemplateCompareTest(unittest.TestCase): 54 | def test_templates(self): 55 | tests = TemplateCheck.load_all() 56 | 57 | for test in tests: 58 | print("Template test: " + test.name) 59 | test.run() 60 | 61 | if not test.passed(): 62 | print("\nHTML (actual): ") 63 | print("\n".join(["%d. %s" % (i + 1, line) for i, line in enumerate(test.actual_html.split("\n"))])) 64 | self._print_diff(test.actual_html, test.expected_html) 65 | self.fail() 66 | 67 | @staticmethod 68 | def _print_diff(s1, s2): 69 | if len(s1) > len(s2): 70 | shorter = s2 71 | else: 72 | shorter = s1 73 | 74 | line = 1 75 | col = 1 76 | 77 | for i, _ in enumerate(shorter): 78 | if len(shorter) <= i + 1: 79 | print("Ran out of characters to compare!") 80 | print("Actual len=%d" % len(s1)) 81 | print("Expected len=%d" % len(s2)) 82 | break 83 | if s1[i] != s2[i]: 84 | print("Difference begins at line", line, "column", col) 85 | actual_line = s1.splitlines()[line - 1] 86 | expected_line = s2.splitlines()[line - 1] 87 | print("HTML (actual, len=%2d) : %s" % (len(actual_line), actual_line)) 88 | print("HTML (expected, len=%2d) : %s" % (len(expected_line), expected_line)) 89 | print("Character code (actual) : %d (%s)" % (ord(s1[i]), s1[i])) 90 | print("Character code (expected): %d (%s)" % (ord(s2[i]), s2[i])) 91 | break 92 | 93 | if shorter[i] == "\n": 94 | line += 1 95 | col = 1 96 | else: 97 | col += 1 98 | else: 99 | print("No Difference Found") 100 | 101 | 102 | def performance_test(num_runs): 103 | """ 104 | Performance test which evaluates all the testing templates a given number of times 105 | """ 106 | print("Loading all templates...") 107 | 108 | template_tests = TemplateCheck.load_all() 109 | 110 | print("Running templates tests...") 111 | 112 | times = [] 113 | 114 | for r in range(num_runs): 115 | start = time.time() 116 | 117 | for test in template_tests: 118 | test.run() 119 | 120 | times.append(time.time() - start) 121 | 122 | print( 123 | "Ran template tests %d times in %.2f seconds (average = %.3f secs)" 124 | % (num_runs, sum(times), sum(times) / float(num_runs)) 125 | ) 126 | 127 | 128 | if __name__ == "__main__": 129 | performance_test(500) 130 | -------------------------------------------------------------------------------- /hamlpy/test/test_templatize.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | 3 | 4 | class TemplatizeTest(SimpleTestCase): 5 | def test_templatize(self): 6 | from django.utils.translation import templatize 7 | 8 | # test regular Django tags 9 | output = templatize('{% trans "Foo" %}{% blocktrans %}\nBar\n{% endblocktrans %}', origin="test.html") 10 | self.assertRegex(output, r"gettext\(u?'Foo'\)") 11 | self.assertRegex(output, r"gettext\(u?'\\nBar\\n'\)") 12 | 13 | # test Haml tags with HTML origin 14 | output = templatize('- trans "Foo"\n- blocktrans\n Bar\n', origin="test.haml") 15 | self.assertRegex(output, r"gettext\(u?'Foo'\)") 16 | self.assertRegex(output, r"gettext\(u?'\\n Bar\\n'\)") 17 | 18 | # test Haml tags and HTML origin 19 | self.assertNotIn("gettext", templatize('- trans "Foo"\n- blocktrans\n Bar\n', origin="test.html")) 20 | 21 | # test Haml tags and no origin 22 | self.assertNotIn("gettext", templatize('- trans "Foo"\n- blocktrans\n Bar\n')) 23 | -------------------------------------------------------------------------------- /hamlpy/test/test_views.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from hamlpy.views.generic import CreateView, DetailView, UpdateView 4 | 5 | 6 | class DummyCreateView(CreateView): 7 | template_name = "create.html" 8 | 9 | 10 | class DummyDetailView(DetailView): 11 | template_name = "detail.htm" 12 | 13 | 14 | class DummyUpdateView(UpdateView): 15 | template_name = "update.xml" 16 | 17 | 18 | class DjangoViewsTest(unittest.TestCase): 19 | def test_get_template_names(self): 20 | assert DummyCreateView().get_template_names() == ["create.haml", "create.hamlpy", "create.html"] 21 | assert DummyDetailView().get_template_names() == ["detail.haml", "detail.hamlpy", "detail.htm"] 22 | assert DummyUpdateView().get_template_names() == ["update.haml", "update.hamlpy", "update.xml"] 23 | -------------------------------------------------------------------------------- /hamlpy/test/test_watcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | import time 5 | import unittest 6 | from unittest.mock import patch 7 | 8 | from hamlpy.hamlpy_watcher import watch_folder 9 | 10 | WORKING_DIR = ".watcher_test" 11 | INPUT_DIR = WORKING_DIR + os.sep + "input" 12 | OUTPUT_DIR = WORKING_DIR + os.sep + "output" 13 | 14 | 15 | class ScriptExit(Exception): 16 | def __init__(self, exit_code): 17 | self.exit_code = exit_code 18 | 19 | 20 | class WatcherTest(unittest.TestCase): 21 | def test_watch_folder(self): 22 | # remove working directory if it exists and re-create it 23 | if os.path.exists(WORKING_DIR): 24 | shutil.rmtree(WORKING_DIR) 25 | 26 | os.makedirs(INPUT_DIR) 27 | 28 | # create some haml files for testing 29 | self._write_file(INPUT_DIR + os.sep + "test.haml", "%span{'class': 'test'}\n- shoutout\n") 30 | self._write_file(INPUT_DIR + os.sep + "error.haml", "%div{") 31 | self._write_file(INPUT_DIR + os.sep + ".#test.haml", "%hr") # will be ignored 32 | 33 | # run as once off pass - should return 1 for number of failed conversions 34 | self._run_script( 35 | [ 36 | "hamlpy_watcher.py", 37 | INPUT_DIR, 38 | OUTPUT_DIR, 39 | "--once", 40 | "--input-extension=haml", 41 | "--verbose", 42 | "--tag=shoutout:endshoutout", 43 | "--django-inline", 44 | "--jinja", 45 | ], 46 | 1, 47 | ) 48 | 49 | # check file without errors was converted 50 | self.assertFileContents( 51 | OUTPUT_DIR + os.sep + "test.html", "\n{% shoutout %}\n{% endshoutout %}\n" 52 | ) 53 | 54 | # check .#test.haml was ignored 55 | assert not os.path.exists(OUTPUT_DIR + os.sep + ".#test.html") 56 | 57 | # run without output directory which should make it default to re-using the input directory 58 | self._run_script(["hamlpy_watcher.py", INPUT_DIR, "--once", "--tag=shoutout:endshoutout"], 1) 59 | 60 | self.assertFileContents( 61 | INPUT_DIR + os.sep + "test.html", "\n{% shoutout %}\n{% endshoutout %}\n" 62 | ) 63 | 64 | # fix file with error 65 | self._write_file(INPUT_DIR + os.sep + "error.haml", "%div{}") 66 | 67 | # check exit code is zero 68 | self._run_script(["hamlpy_watcher.py", INPUT_DIR, OUTPUT_DIR, "--once"], 0) 69 | 70 | # run in watch mode with 1 second refresh 71 | self._run_script( 72 | ["hamlpy_watcher.py", INPUT_DIR, "--refresh=1", "--input-extension=haml", "--tag=shoutout:endshoutout"], 1 73 | ) 74 | 75 | def assertFileContents(self, path, contents): 76 | with open(path, "r") as f: 77 | self.assertEqual(f.read(), contents) 78 | 79 | def _write_file(self, path, text): 80 | with open(path, "w") as f: 81 | f.write(text) 82 | 83 | def _run_script(self, script_args, expected_exit_code): 84 | def raise_exception_with_code(code): 85 | raise ScriptExit(code) 86 | 87 | # patch sys.exit so it throws an exception so we can return execution to this test 88 | # patch sys.argv to pass our arguments to the script 89 | # patch time.sleep to be interrupted 90 | with patch.object(sys, "exit", side_effect=raise_exception_with_code), patch.object( 91 | sys, "argv", script_args 92 | ), patch.object(time, "sleep", side_effect=KeyboardInterrupt), self.assertRaises( 93 | ScriptExit 94 | ) as raises: # noqa 95 | watch_folder() 96 | 97 | assert raises.exception.exit_code == expected_exit_code 98 | -------------------------------------------------------------------------------- /hamlpy/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/django-hamlpy/59e87ee4fceb84adeabef8358a95572544683c7a/hamlpy/views/__init__.py -------------------------------------------------------------------------------- /hamlpy/views/generic/__init__.py: -------------------------------------------------------------------------------- 1 | import django.views.generic 2 | 3 | from hamlpy import HAML_EXTENSIONS 4 | 5 | pouet = [ 6 | "ArchiveIndexView", 7 | "YearArchiveView", 8 | "MonthArchiveView", 9 | "WeekArchiveView", 10 | "DayArchiveView", 11 | "TodayArchiveView", 12 | "DateDetailView", 13 | "DetailView", 14 | "CreateView", 15 | "UpdateView", 16 | "DeleteView", 17 | "ListView", 18 | ] 19 | 20 | NON_HAML_EXTENSIONS = ("html", "htm", "xml") 21 | 22 | 23 | class HamlExtensionTemplateView(object): 24 | def get_template_names(self): 25 | names = super(HamlExtensionTemplateView, self).get_template_names() 26 | 27 | haml_names = [] 28 | 29 | for name in names: 30 | for ext in NON_HAML_EXTENSIONS: 31 | if name.endswith("." + ext): 32 | base_name = name[: -len(ext)] 33 | 34 | for haml_ext in HAML_EXTENSIONS: 35 | haml_names.append(base_name + haml_ext) 36 | 37 | return haml_names + names 38 | 39 | 40 | for view in pouet: 41 | locals()[view] = type(str(view), (HamlExtensionTemplateView, getattr(django.views.generic, view)), {}) 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-hamlpy" 3 | version = "1.7.0" 4 | description = "HAML like syntax for Django templates" 5 | authors = ["Nyaruka"]
6 | readme = "README.md"
7 | license = "MIT"
8 | classifiers=[
9 | "Environment :: Web Environment",
10 | "Intended Audience :: Developers",
11 | "License :: OSI Approved :: MIT License",
12 | "Operating System :: OS Independent",
13 | "Programming Language :: Python",
14 | "Framework :: Django",
15 | ]
16 | packages = [
17 | { include = "hamlpy" },
18 | ]
19 |
20 | [tool.poetry.urls]
21 | repository = "http://github.com/nyaruka/django-hamlpy"
22 |
23 | [tool.poetry.dependencies]
24 | python = "^3.9"
25 | django = ">=3.2,<5.0"
26 | regex = ">=2020.1.1"
27 |
28 | [tool.poetry.dev-dependencies]
29 | Markdown = "^3.4.1"
30 | pytest = "^6.1.2"
31 | pytest-cov = "^4.0.0"
32 | Pygments = "^2.14.0"
33 | Jinja2 = "^3.1.2"
34 | black = "^23.1.0"
35 | isort = "^5.11.0"
36 |
37 | [tool.poetry.scripts]
38 | hamlpy-watcher = "hamlpy.hamlpy_watcher:watch_folder"
39 |
40 | [tool.poetry.group.dev.dependencies]
41 | ruff = "^0.0.253"
42 |
43 | [tool.black]
44 | line-length = 119
45 |
46 | [tool.ruff]
47 | line-length = 120
48 | select = ["E", "F", "W"]
49 | ignore = ["E501", "F405"]
50 | fix = true
51 | exclude = ["./.tox/*", "./.venv/*", "./env/*", "*/migrations/*", "./build/*"]
52 |
53 | [tool.isort]
54 | multi_line_output = 3
55 | force_grid_wrap = 0
56 | line_length = 119
57 | include_trailing_comma = true
58 | combine_as_imports = true
59 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "DJANGO", "FIRSTPARTY", "LOCALFOLDER"]
60 | known_django = ["django"]
61 |
62 | [tool.coverage.run]
63 | source = ["hamlpy"]
64 |
65 | [tool.coverage.report]
66 | omit = ["*/migrations/*", "*/tests*", "*__init__*", "*settings*"]
67 |
68 |
69 | [build-system]
70 | requires = ["poetry-core>=1.0.0"]
71 | build-backend = "poetry.core.masonry.api"
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [coverage:run]
2 | source = hamlpy
3 | omit = hamlpy/test/*
4 |
5 | [flake8]
6 | max-line-length = 120
7 | exclude = ./env/*,./build/*,./dist/*,./docs/*
8 | ignore = E501,E203,W503
9 |
10 | [isort]
11 | multi_line_output = 3
12 | include_trailing_comma = True
13 | force_grid_wrap = 0
14 | line_length = 119
15 | combine_as_imports = True
16 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
17 |
18 | [wheel]
19 | universal = 1
20 |
21 |
--------------------------------------------------------------------------------