├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE-MIT ├── MANIFEST.in ├── README.md ├── manage.py ├── pytest.ini ├── sass_processor ├── __init__.py ├── apps.py ├── finders.py ├── jinja2 │ ├── __init__.py │ └── ext.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── compilescss.py ├── processor.py ├── storage.py ├── templatetags │ ├── __init__.py │ └── sass_tags.py ├── types.py └── utils.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── jinja2.py ├── jinja2 └── tests │ ├── jinja2.html │ └── jinja2_variable.html ├── requirements.txt ├── settings.py ├── static └── tests │ └── css │ ├── _redbox.scss │ ├── bluebox.scss │ └── main.scss ├── templates └── tests │ └── django.html └── test_sass_processor.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish django-sass-processor 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | name: "Publish release" 11 | runs-on: "ubuntu-latest" 12 | 13 | environment: 14 | name: deploy 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.11"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install build --user 30 | - name: Build 🐍 Python 📦 Package 31 | run: python -m build --sdist --wheel --outdir dist/ 32 | - name: Publish 🐍 Python 📦 Package to PyPI 33 | if: startsWith(github.ref, 'refs/tags') 34 | uses: pypa/gh-action-pypi-publish@master 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN_SASS_PROCESSOR }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 16 | django-version: ["3.2", "4.2", "5.0", "5.1", "5.2"] 17 | exclude: 18 | - python-version: 3.11 19 | django-version: 3.2 20 | - python-version: 3.12 21 | django-version: 3.2 22 | - python-version: 3.13 23 | django-version: 3.2 24 | - python-version: 3.13 25 | django-version: 4.2 26 | - python-version: 3.13 27 | django-version: 5.0 28 | - python-version: 3.8 29 | django-version: 5.0 30 | - python-version: 3.9 31 | django-version: 5.0 32 | - python-version: 3.8 33 | django-version: 5.1 34 | - python-version: 3.9 35 | django-version: 5.1 36 | - python-version: 3.8 37 | django-version: 5.2 38 | - python-version: 3.9 39 | django-version: 5.2 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Set up Python ${{ matrix.python-version }} 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install Dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install "Django==${{ matrix.django-version }}.*" 51 | pip install -r tests/requirements.txt 52 | python setup.py install 53 | - name: Test with pytest 54 | run: | 55 | python -m pytest tests 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/macos,linux,python,django,windows 2 | 3 | ### Django ### 4 | *.log 5 | *.pot 6 | *.pyc 7 | __pycache__/ 8 | local_settings.py 9 | db.sqlite3 10 | media 11 | .pytest_cache 12 | 13 | ### Linux ### 14 | *~ 15 | 16 | # temporary files which can be created if a process still has a handle open of a deleted file 17 | .fuse_hidden* 18 | 19 | # KDE directory preferences 20 | .directory 21 | 22 | # Linux trash folder which might appear on any partition or disk 23 | .Trash-* 24 | 25 | # .nfs files are created when an open file is removed but is still being accessed 26 | .nfs* 27 | 28 | ### macOS ### 29 | *.DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | # Thumbnails 37 | ._* 38 | 39 | # Files that might appear in the root of a volume 40 | .DocumentRevisions-V100 41 | .fseventsd 42 | .Spotlight-V100 43 | .TemporaryItems 44 | .Trashes 45 | .VolumeIcon.icns 46 | .com.apple.timemachine.donotpresent 47 | 48 | # Directories potentially created on remote AFP share 49 | .AppleDB 50 | .AppleDesktop 51 | Network Trash Folder 52 | Temporary Items 53 | .apdisk 54 | 55 | ### Python ### 56 | # Byte-compiled / optimized / DLL files 57 | *.py[cod] 58 | *$py.class 59 | 60 | # C extensions 61 | *.so 62 | 63 | # Distribution / packaging 64 | .Python 65 | env/ 66 | build/ 67 | develop-eggs/ 68 | dist/ 69 | downloads/ 70 | eggs/ 71 | .eggs/ 72 | lib/ 73 | lib64/ 74 | parts/ 75 | sdist/ 76 | var/ 77 | wheels/ 78 | *.egg-info/ 79 | .installed.cfg 80 | *.egg 81 | 82 | # PyInstaller 83 | # Usually these files are written by a python script from a template 84 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 85 | *.manifest 86 | *.spec 87 | 88 | # Installer logs 89 | pip-log.txt 90 | pip-delete-this-directory.txt 91 | 92 | # Unit test / coverage reports 93 | htmlcov/ 94 | .tox/ 95 | .coverage 96 | .coverage.* 97 | .cache 98 | nosetests.xml 99 | coverage.xml 100 | *,cover 101 | .hypothesis/ 102 | 103 | # Translations 104 | *.mo 105 | 106 | # Django stuff: 107 | 108 | # Flask stuff: 109 | instance/ 110 | .webassets-cache 111 | 112 | # Scrapy stuff: 113 | .scrapy 114 | 115 | # Sphinx documentation 116 | docs/_build/ 117 | 118 | # PyBuilder 119 | target/ 120 | 121 | # Jupyter Notebook 122 | .ipynb_checkpoints 123 | 124 | # pyenv 125 | .python-version 126 | 127 | # celery beat schedule file 128 | celerybeat-schedule 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # dotenv 134 | .env 135 | 136 | # virtualenv 137 | .venv 138 | venv/ 139 | ENV/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | ### Windows ### 152 | # Windows thumbnail cache files 153 | Thumbs.db 154 | ehthumbs.db 155 | ehthumbs_vista.db 156 | 157 | # Folder config file 158 | Desktop.ini 159 | 160 | # Recycle Bin used on file shares 161 | $RECYCLE.BIN/ 162 | 163 | # Windows Installer files 164 | *.cab 165 | *.msi 166 | *.msm 167 | *.msp 168 | 169 | # Windows shortcuts 170 | *.lnk 171 | 172 | # End of https://www.gitignore.io/api/macos,linux,python,django,windows 173 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes for django-sass-processor 2 | 3 | - 1.4.2 4 | * Add support for Python 3.13 and Django 5.2. 5 | 6 | - 1.4.1 7 | * Fix compatibility with Django 5.1. 8 | 9 | - 1.4 10 | * Drop support for Python 3.7. 11 | * Add support for Python 3.12. 12 | * Drop support for Django 4.0 and 4.1. 13 | * Add support for Django 5.0. 14 | 15 | - 1.3 16 | * Add support for Django-4.2. 17 | * Update settings for Django >= 4.2.* to use `STORAGES `, instead of `STATICFILES_STORAGE`. 18 | * Remove usage of deprecated `get_storage_class` for Django >= 4.2.*. 19 | 20 | - 1.2.2 21 | * Revert regression introduced in version 1.1: Remove compatibility layer to support 22 | Django's `ManifestStaticFilesStorage`. 23 | 24 | - 1.2.1 25 | * In newer versions of Django `default_app_config`is deprecated. 26 | 27 | - 1.2 28 | * Stringify directory settings, since they might use Python's `pathlib.Path` class. 29 | * Add support for Django-4.0. 30 | 31 | - 1.1 32 | * Add compatibility layer to support Django's `ManifestStaticFilesStorage`. 33 | 34 | - 1.0.1 35 | * Fix storage options for non-filesystem static storages (thanks to TheRandomDog for finding) 36 | * Dev: Move to setup.cfg configuration 37 | 38 | - 1.0.0 39 | * Management command `compilescss` now uses the same storage as the template tags. 40 | * Any storage can now be used as destination. 41 | * Breaking change: The argument `--use-processor-root` to `compilescss` was replaced 42 | with `--use-storage`. 43 | * Breaking change: `SassS3Boto3Storage` was removed. Use the `S3Boto3Storage` from 44 | django-storages directly. 45 | * Breaking change: Suppor for Django <2.2 was dropped 46 | * Dev: Migrated setup meta-data to setup.cfg 47 | * Dev: Enabled tests on Python 3.9 48 | 49 | - 0.8.2 50 | * Fixes: Management command `find_sources` does not ignore `SASS_PROCESSOR_AUTO_INCLUDE`. 51 | 52 | - 0.8.1 53 | * Add support for Django-3.1. 54 | 55 | - 0.8 56 | * Add support for Django-3.0. 57 | * Drop support for Python<3. 58 | 59 | - 0.7.5 60 | * Latest version to support Python-2.7. Tested with Django-1.9, Django-1.10, Django-1.11, Django-2.0 61 | Django-2.1 and Django-2.2 using Python-3.5...3.7. 62 | 63 | - 0.7.4 64 | * Prevent the warnings about `Found another file with the destination path ...`, while 65 | running `./manage.py collectstatic`. 66 | 67 | - 0.7.3 68 | * In managment command `compilescss`, also catch `IndentionError` of parsed files. 69 | 70 | - 0.7.2 71 | * Prevent empty content when using autoprefixer. 72 | 73 | * Source Map is now using relative paths. This fixes the path naming problems on Windows platforms. 74 | - 0.7.1 75 | 76 | * Source Map is now using relative paths. This fixes the path naming problems on Windows platforms. 77 | 78 | 79 | - 0.7 80 | 81 | * Allow to call directly into Python functions. 82 | 83 | - 0.6 84 | 85 | * Add autoprefixing via external postcss. 86 | 87 | - 0.5.8 88 | 89 | * _Potentially Breaking_: `libsass` is not autoinstalled as the dependency anymore. 90 | * Add support for Django-2.0. 91 | 92 | - 0.5.7 93 | 94 | * Fixed: Catch exception if s3boto is not installed. 95 | 96 | - 0.5.6 97 | 98 | * Added compatibility layer to work with AWS S3 Storage. 99 | 100 | - 0.5.5 101 | 102 | * Create directory `SASS_PROCESSOR_ROOT` if it does not exist. 103 | 104 | - 0.5.4 105 | 106 | * Added unit tests and continuous integration to the project. 107 | 108 | - 0.5.3 109 | 110 | * Fixed compilescss: Did not find calls of sass_processor within a dict, list or tuple 111 | 112 | - 0.5.2 113 | 114 | * Fixed Python 3 incompatibility. Open files as binaries, since they may contain unicode characters. 115 | 116 | - 0.5.1 117 | 118 | * Add `APPS_INCLUDE_DIRS` to the SASS include path. 119 | 120 | - 0.5.0 121 | 122 | * SASS/SCSS files can also be referenced in pure Python files, for instance in `Media` class or 123 | `media` property definitions. 124 | * The SASS processor will look for potential include directories, so that the `@import "..."` 125 | statement also works for SASS files located in other Django apps. 126 | 127 | - 0.4.0 - 0.4.4 128 | 129 | * Refactored the sass processor into a self-contained class `SassProcessor`, which can be accessed 130 | through an API, the Jinja2 template engine and the existing templatetag. 131 | 132 | - 0.3.5 133 | 134 | * Added Jinja2 support, see [Jinja2 support](#jinja2-support). 135 | 136 | - 0.3.4 137 | 138 | * Fixed: `get_template_sources()` in Django-1.9 returns Objects rather than strings. 139 | * In command, use `ArgumentParser` rather than `OptionParser`. 140 | 141 | - 0.3.1...0.3.3 142 | 143 | * Changed the build process in `setup.py`. 144 | 145 | - 0.3.0 146 | 147 | * Compatible with Django 1.8+. 148 | * bootstrap3-sass ready: appropriate floating point precision (8) can be set in `settings.py`. 149 | * Offline compilation results may optionally be stored in `SASS_PROCESSOR_ROOT`. 150 | 151 | - 0.2.6 152 | 153 | * Hotfix: added SASS function `get-setting` also to offline compiler. 154 | 155 | - 0.2.5 156 | 157 | * Compatible with Python3 158 | * Replaced `SortedDict` with `OrderedDict` to be prepared for Django-1.9 159 | * Raise a `TemplateSyntax` error, if a SASS `@include "..."` fails to find the file. 160 | * Added SASS function `get-setting` to fetch configuration directives from `settings.py`. 161 | 162 | - 0.2.4 163 | 164 | * Forcing compiled unicode to bytes, since 'Font Awesome' uses Unicode Private Use Area (PUA) 165 | and hence implicit conversion on `fh.write()` failed. 166 | 167 | - 0.2.3 168 | 169 | * Allow for setting template extensions and output style. 170 | * Force Django to calculate template_source_loaders from TEMPLATE_LOADERS settings, by asking to find a dummy template. 171 | 172 | - 0.2.0 173 | 174 | * Removed dependency to **django-sekizai** and **django-classy-tags**. It now can operate in 175 | stand-alone mode. Therefore the project has been renamed to **django-sass-processor**. 176 | 177 | - 0.1.0 178 | 179 | * Initial revision named **django-sekizai-processors**, based on a preprocessor for the Sekizai 180 | template tags `{% addtoblock %}`. 181 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 Jacob Rief 4 | Copyright (c) 2021 Dominik George 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests/ *.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-sass-processor 2 | 3 | Annoyed having to run a Compass, Grunt or Gulp daemon while developing Django projects? 4 | 5 | Well, then this app is for you! Compile SASS/SCSS files on the fly without having to manage 6 | third party services nor special IDE plugins. 7 | 8 | [![Build Status](https://github.com/jrief/django-sass-processor/actions/workflows/tests.yml/badge.svg)](https://github.com/jrief/django-sass-processor/actions) 9 | [![PyPI](https://img.shields.io/pypi/pyversions/django-sass-processor.svg)]() 10 | [![PyPI version](https://img.shields.io/pypi/v/django-sass-processor.svg)](https://pypi.python.org/pypi/django-sass-processor) 11 | [![PyPI](https://img.shields.io/pypi/l/django-sass-processor.svg)]() 12 | [![Downloads](https://img.shields.io/pypi/dm/django-sass-processor.svg)](https://pypi.python.org/pypi/django-sass-processor) 13 | [![Twitter Follow](https://img.shields.io/twitter/follow/shields_io.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/jacobrief) 14 | 15 | 16 | ## Other good reasons for using this library 17 | 18 | * Refer SASS/SCSS files directly from your sources, instead of referring a compiled CSS file, 19 | having to rely on another utility which creates them from SASS/SCSS files, hidden in 20 | your source tree. 21 | * Use Django's `settings.py` for the configuration of paths, box sizes etc., instead of having another 22 | SCSS specific file (typically `_variables.scss`), to hold these. 23 | * Extend your SASS functions by calling Python functions directly out of your Django project. 24 | * View SCSS errors directly in the debug console of your Django's development server. 25 | 26 | **django-sass-processor** converts `*.scss` or `*.sass` files into `*.css` while rendering 27 | templates. For performance reasons this is done only once, since the preprocessor keeps track on 28 | the timestamps and only recompiles, if any of the imported SASS/SCSS files is younger than the 29 | corresponding generated CSS file. 30 | 31 | ## Introduction 32 | 33 | This Django app provides a templatetag `{% sass_src 'path/to/file.scss' %}`, which can be used 34 | instead of the built-in templatetag `static`. This templatetag also works inside Jinja2 templates. 35 | 36 | If SASS/SCSS files shall be referenced through the `Media` class, or `media` property, the SASS 37 | processor can be used directly. 38 | 39 | Additionally, **django-sass-processor** is shipped with a management command, which can convert 40 | the content of all occurrences inside the templatetag `sass_src` as an offline operation. Hence 41 | the **libsass** compiler is not required in a production environment. 42 | 43 | During development, a [sourcemap](https://developer.chrome.com/devtools/docs/css-preprocessors) is 44 | generated along side with the compiled `*.css` file. This allows to debug style sheet errors much 45 | easier. 46 | 47 | With this tool, you can safely remove your Ruby installations "Compass" and "SASS" from your Django 48 | projects. You neither need any directory "watching" daemons based on node.js. 49 | 50 | ## Project's Home 51 | 52 | On GitHub: 53 | 54 | https://github.com/jrief/django-sass-processor 55 | 56 | Please use the issue tracker to report bugs or propose new features. 57 | 58 | ## Installation 59 | 60 | ``` 61 | pip install libsass django-compressor django-sass-processor 62 | ``` 63 | 64 | `django-compressor` is required only for offline compilation, when using the command 65 | `manage.py compilescss`. 66 | 67 | `libsass` is not required on the production environment, if SASS/SCSS files have been precompiled 68 | and deployed using offline compilation. 69 | 70 | ## Configuration 71 | 72 | In `settings.py` add to: 73 | 74 | ```python 75 | INSTALLED_APPS = [ 76 | ... 77 | 'sass_processor', 78 | ... 79 | ] 80 | ``` 81 | 82 | **django-sass-processor** is shipped with a special finder, to locate the generated `*.css` files 83 | in the directory referred by `STORAGES['sass_processor']['ROOT']` (for Django >= 4.2.*) or 84 | `SASS_PROCESSOR_ROOT` (for Django <= 4.1.*), or, if unset `STATIC_ROOT`. Just add it to 85 | your `settings.py`. If there is no `STATICFILES_FINDERS` in your `settings.py` don't forget 86 | to include the **Django** [default finders](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-STATICFILES_FINDERS). 87 | 88 | ```python 89 | STATICFILES_FINDERS = [ 90 | 'django.contrib.staticfiles.finders.FileSystemFinder', 91 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 92 | 'sass_processor.finders.CssFinder', 93 | ... 94 | ] 95 | ``` 96 | 97 | Optionally, add a list of additional search paths, the SASS compiler may examine when using the 98 | `@import "...";` statement in SASS/SCSS files: 99 | 100 | ```python 101 | import os 102 | 103 | SASS_PROCESSOR_INCLUDE_DIRS = [ 104 | os.path.join(PROJECT_PATH, 'extra-styles/scss'), 105 | os.path.join(PROJECT_PATH, 'node_modules'), 106 | ] 107 | ``` 108 | 109 | Additionally, **django-sass-processor** will traverse all installed Django apps (`INSTALLED_APPS`) 110 | and look into their static folders. If any of them contain a file matching the regular expression 111 | pattern `^_.+\.(scss|sass)$` (read: filename starts with an underscore and is of type `scss` or 112 | `sass`), then that app specific static folder is added to the **libsass** include dirs. This 113 | feature can be disabled in your settings with: 114 | 115 | ```python 116 | SASS_PROCESSOR_AUTO_INCLUDE = False 117 | ``` 118 | 119 | If inside of your SASS/SCSS files, you also want to import (using `@import "path/to/scssfile";`) 120 | files which do not start with an underscore, then you can configure another Regex pattern in your 121 | settings, for instance: 122 | 123 | ```python 124 | SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r'^.+\.scss$' 125 | ``` 126 | 127 | will look for all files of type `scss`. Remember that SASS/SCSS files which start with an 128 | underscore, are intended to be imported by other SASS/SCSS files, while files starting with a 129 | letter or number are intended to be included by the HTML tag 130 | ``. 131 | 132 | During development, or when `SASS_PROCESSOR_ENABLED = True`, the compiled file is placed into the 133 | folder referenced by `STORAGES['sass_processor']['ROOT']` (for Django >= 4.2.*) or `SASS_PROCESSOR_ROOT` (for Django <= 4.1.*). 134 | If unset, this setting defaults to `STATIC_ROOT`. 135 | Having a location outside of the working directory prevents to pollute your local `static/css/...` 136 | directories with auto-generated files. Therefore assure, that this directory is writable by the 137 | Django runserver. 138 | 139 | 140 | #### Fine tune SASS compiler parameters in `settings.py`. 141 | 142 | Integer `SASS_PRECISION` sets floating point precision for output css. libsass' 143 | default is `5`. Note: **bootstrap-sass** requires `8`, otherwise various 144 | layout problems _will_ occur. 145 | 146 | ```python 147 | SASS_PRECISION = 8 148 | ``` 149 | 150 | `SASS_OUTPUT_STYLE` sets coding style of the compiled result, one of `compact`, 151 | `compressed`, `expanded`, or `nested`. Default is `nested` for `DEBUG` 152 | and `compressed` in production. 153 | 154 | Note: **libsass-python** 0.8.3 has [problem encoding result while saving on 155 | Windows](https://github.com/dahlia/libsass-python/pull/82), the issue is already 156 | fixed and will be included in future `pip` package release, in the meanwhile 157 | avoid `compressed` output style. 158 | 159 | ```python 160 | SASS_OUTPUT_STYLE = 'compact' 161 | ``` 162 | 163 | ### Jinja2 support 164 | 165 | `sass_processor.jinja2.ext.SassSrc` is a Jinja2 extension. Add it to your Jinja2 environment to enable the tag `sass_src`, there is no need for a `load` tag. Example of how to add your Jinja2 environment to Django: 166 | 167 | In `settings.py`: 168 | 169 | ```python 170 | TEMPLATES = [{ 171 | 'BACKEND': 'django.template.backends.jinja2.Jinja2', 172 | 'DIRS': [], 173 | 'APP_DIRS': True, 174 | 'OPTIONS': { 175 | 'environment': 'yourapp.jinja2.environment' 176 | }, 177 | ... 178 | }] 179 | ``` 180 | 181 | Make sure to add the default template backend, if you're still using Django templates elsewhere. 182 | This is covered in the [Upgrading templates documentation](https://docs.djangoproject.com/en/stable/ref/templates/upgrading/). 183 | 184 | In `yourapp/jinja2.py`: 185 | 186 | ```python 187 | # Include this for Python 2. 188 | from __future__ import absolute_import 189 | 190 | from jinja2 import Environment 191 | 192 | 193 | def environment(**kwargs): 194 | extensions = [] if 'extensions' not in kwargs else kwargs['extensions'] 195 | extensions.append('sass_processor.jinja2.ext.SassSrc') 196 | kwargs['extensions'] = extensions 197 | 198 | return Environment(**kwargs) 199 | ``` 200 | 201 | If you want to make use of the `compilescss` command, then you will also have to add the following to your settings: 202 | 203 | ```python 204 | from yourapp.jinja2 import environment 205 | 206 | COMPRESS_JINJA2_GET_ENVIRONMENT = environment 207 | ``` 208 | 209 | 210 | ## Usage 211 | 212 | ### In your Django templates 213 | 214 | ```django 215 | {% load sass_tags %} 216 | 217 | 218 | ``` 219 | 220 | The above template code will be rendered as HTML 221 | 222 | ```html 223 | 224 | ``` 225 | 226 | You can safely use this templatetag inside a [Sekizai](https://django-sekizai.readthedocs.io/)'s 227 | `{% addtoblock "css" %}` statement. 228 | 229 | ### In Media classes or properties 230 | 231 | In Python code, you can access the API of the SASS processor directly. This for instance is useful 232 | in Django's admin or form framework. 233 | 234 | ```python 235 | from sass_processor.processor import sass_processor 236 | 237 | class SomeAdminOrFormClass(...): 238 | ... 239 | class Media: 240 | css = { 241 | 'all': [sass_processor('myapp/css/mystyle.scss')], 242 | } 243 | ``` 244 | 245 | ## Add vendor prefixes to CSS rules using values from https://caniuse.com/ 246 | 247 | Writing SCSS shall be fast and easy and you should not have to care, whether to add vendor specific 248 | prefixes to your CSS directives. Unfortunately there is no pure Python package to solve this, but 249 | with a few node modules, we can add this to our process chain. 250 | 251 | As superuser install 252 | 253 | ```shell 254 | npm install -g npx 255 | ``` 256 | 257 | and inside your project root, install 258 | 259 | ```shell 260 | npm install postcss-cli autoprefixer 261 | ``` 262 | 263 | Check that the path of `node_modules` corresponds to its entry in the settings directive 264 | `STATICFILES_DIRS` (see below). 265 | 266 | In case `npx` can not be found in your system path, use the settings directive 267 | `NODE_NPX_PATH = /path/to/npx` to point to that executable. 268 | 269 | If everything is setup correctly, **django-sass-processor** adds all required vendor prefixes to 270 | the compiled CSS files. For further information, refer to the 271 | [Autoprefixer](https://github.com/postcss/autoprefixer) package. 272 | 273 | To disable autoprefixing, set `NODE_NPX_PATH = None`. 274 | 275 | **Important note**: If `npx` is installed, but `postcss` and/or `autoprefixer` are missing 276 | in the local `node_modules`, setting `NODE_NPX_PATH` to `None` is manadatory, otherwise 277 | **django-sass-processor** does not know how to postprocess the generated CSS files. 278 | 279 | ## Offline compilation 280 | 281 | If you want to precompile all occurrences of your SASS/SCSS files for the whole project, on the 282 | command line invoke: 283 | 284 | ```shell 285 | ./manage.py compilescss 286 | ``` 287 | 288 | This is useful for preparing production environments, where SASS/SCSS files can't be compiled on 289 | the fly. 290 | 291 | To simplify the deployment, the compiled `*.css` files are stored side-by-side with their 292 | corresponding SASS/SCSS files. After compiling the files run 293 | 294 | ```shell 295 | ./manage.py collectstatic 296 | ``` 297 | 298 | as you would in a normal deployment. 299 | 300 | In case you don't want to expose the SASS/SCSS files in a production environment, 301 | deploy with: 302 | 303 | ```shell 304 | ./manage.py collectstatic --ignore=*.scss 305 | ``` 306 | 307 | To get rid of the compiled `*.css` files in your local static directories, simply reverse the 308 | above command: 309 | 310 | ```shell 311 | ./manage.py compilescss --delete-files 312 | ``` 313 | 314 | This will remove all occurrences of previously generated `*.css` files. 315 | 316 | Or you may compile results to the `STORAGES['sass_processor']['ROOT']` [Django >= 4.2.*] or `SASS_PROCESSOR_ROOT` 317 | [Django <= 4.1.*] directory directy (if not specified - to 318 | `STATIC_ROOT`): 319 | 320 | ```shell 321 | ./manage.py compilescss --use-storage 322 | ``` 323 | 324 | Combine with `--delete-files` switch to purge results from there. 325 | 326 | If you use an alternative templating engine set its name in `--engine` argument. Currently 327 | `django` and `jinja2` are supported, see 328 | [django-compressor documentation](http://django-compressor.readthedocs.org/en/latest/) on how to 329 | set up `COMPRESS_JINJA2_GET_ENVIRONMENT` to configure jinja2 engine support. 330 | 331 | During offline compilation **django-sass-processor** parses all Python files and looks for 332 | invocations of `sass_processor('path/to/sassfile.scss')`. Therefore the string specifying 333 | the filename must be hard coded and shall not be concatenated or being somehow generated. 334 | 335 | ### Alternative templates 336 | 337 | By default, **django-sass-processor** will locate SASS/SCSS files from .html templates, 338 | but you can extend or override this behavior in your settings with: 339 | 340 | ```python 341 | SASS_TEMPLATE_EXTS = ['.html','.jade'] 342 | ``` 343 | 344 | ## Configure SASS variables through settings.py 345 | 346 | In SASS, a nasty problem is to set the correct include paths for icons and fonts. Normally this is 347 | done through a `_variables.scss` file, but this inhibits a configuration through your projects 348 | `settings.py`. 349 | 350 | To avoid the need for duplicate configuration settings, **django-sass-processor** offers a SASS 351 | function to fetch any arbitrary configuration directive from the project's `settings.py`. This 352 | is specially handy to set the include path of your Glyphicons font directory. Assume, Bootstrap-SASS 353 | has been installed using: 354 | 355 | ```shell 356 | npm install bootstrap-sass 357 | ``` 358 | 359 | then locate the directory named `node_modules` and add it to your settings, so that your fonts are 360 | accessible through the Django's `django.contrib.staticfiles.finders.FileSystemFinder`: 361 | 362 | ```python 363 | STATICFILES_DIRS = [ 364 | ... 365 | ('node_modules', '/path/to/your/project/node_modules/'), 366 | ... 367 | ] 368 | 369 | NODE_MODULES_URL = STATIC_URL + 'node_modules/' 370 | ``` 371 | 372 | With the SASS function `get-setting`, it is possible to override any SASS variable with a value 373 | configured in the project's `settings.py`. For the Glyphicons font search path, add this to your 374 | `_variables.scss`: 375 | 376 | ```scss 377 | $icon-font-path: unquote(get-setting(NODE_MODULES_URL) + "bootstrap-sass/assets/fonts/bootstrap/"); 378 | ``` 379 | 380 | and `@import "variables";` whenever you need Glyphicons. You then can safely remove any font 381 | references, such as `` 382 | from you HTML templates. 383 | 384 | 385 | ### Configure SASS variables through Python functions 386 | 387 | It is even possible to call Python functions from inside any module. Do this by adding 388 | `SASS_PROCESSOR_CUSTOM_FUNCTIONS` to the project's `settings.py`. This shall contain a mapping 389 | of SASS function names pointing to a Python function name. 390 | 391 | Example: 392 | 393 | ```python 394 | SASS_PROCESSOR_CUSTOM_FUNCTIONS = { 395 | 'get-color': 'myproject.utils.get_color', 396 | } 397 | ``` 398 | 399 | This allows to invoke Python functions out of any `*.scss` file. 400 | 401 | ```scss 402 | $color: get-color(250, 10, 120); 403 | ``` 404 | 405 | Here we pass the parameters '250, 10, 120' into the function `def get_color(red, green, blue)` 406 | in Python module `myproject.utils`. Note that this function receives the values as `sass.Number`, 407 | hence extract values using `red.value`, etc. 408 | 409 | If one of these customoized functions returns a value, which is not a string, then convert it 410 | either to a Python string or to a value of type `sass.SassNumber`. For other types, refer to their 411 | documentation. 412 | 413 | Such customized functions must accept parameters explicilty, otherwise `sass_processor` does not 414 | know how to map them. Variable argument lists therefore can not be used. 415 | 416 | 417 | ## Error reporting 418 | 419 | Whenever **django-sass-processor** runs in debug mode and fails to compile a SASS/SCSS file, it 420 | raises a `sass.CompileError` exception. This shows the location of the error directly on the 421 | Django debug console and is very useful during development. 422 | 423 | This behaviour can be overridden using the settings variable `SASS_PROCESSOR_FAIL_SILENTLY`. 424 | If it is set to `True`, instead of raising that exception, the compilation error message is send 425 | to the Django logger. 426 | 427 | 428 | ## Using other storage backends for compiled CSS files 429 | 430 | Under the hood, SASS processor will use any storage configured in your settings as `STORAGES['staticfiles']`. 431 | This means you can use anything you normally use for serving static files, e.g. S3. 432 | 433 | A custom Storage class can be used if your deployment needs to serve generated CSS files from elsewhere, 434 | e.g. when your static files storage is not writable at runtime and you nede to re-compile CSS 435 | in production. To use a custom storage, configure it in `STORAGES['sass_processor']['BACKEND']`. You can also 436 | configure a dictionary with options that will be passed to the storage class as keyword arguments 437 | in `STORAGES['sass_processor']['OPTIONS']` [Django >= 4.2.*] or `SASS_PROCESSOR_STORAGE_OPTIONS` [Django <= 4.1.*>] 438 | (e.g. if you want to use `FileSystemStorage`, but with a different `location` or `base_url`: 439 | 440 | ```python 441 | # For Django >= 4.2.* 442 | STORAGES['sass_processor'] = { 443 | 'BACKEND': 'django.core.files.storage.FileSystemStorage', 444 | 'OPTIONS': { 445 | 'location': '/srv/media/generated', 446 | 'base_url': 'https://media.myapp.example.com/generated' 447 | } 448 | } 449 | 450 | # For Django <= 4.1.* 451 | SASS_PROCESSOR_STORAGE = 'django.core.files.storage.FileSystemStorage' 452 | SASS_PROCESSOR_STORAGE_OPTIONS = { 453 | 'location': '/srv/media/generated', 454 | 'base_url': 'https://media.myapp.example.com/generated' 455 | } 456 | ``` 457 | 458 | 459 | ### Amazon's S3 Storage 460 | 461 | Using the S3 storage backend from [django-storages](https://django-storages.readthedocs.io/en/latest/) 462 | with its regular configuration (if you do not otherwise use it for service static files): 463 | 464 | ```python 465 | # For Django >= 4.2.* 466 | STORAGES['sass_processor'] = { 467 | 'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage' 468 | } 469 | 470 | # For Django <= 4.1.* 471 | SASS_PROCESSOR_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 472 | ``` 473 | 474 | 475 | ## Heroku 476 | 477 | If you are deploying to [Heroku](https://www.heroku.com/), use the 478 | [heroku-buildpack-django-sass](https://elements.heroku.com/buildpacks/drpancake/heroku-buildpack-django-sass) 479 | buildpack to automatically compile scss for you. 480 | 481 | 482 | ## Development 483 | 484 | To run the tests locally, clone the repository, and, in your local copy, create a new virtualenv. 485 | these commands: 486 | 487 | ```shell 488 | python -m pip install --upgrade pip 489 | pip install Django 490 | pip install -r tests/requirements.txt 491 | python -m pytest tests 492 | ``` 493 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # This file only exists to make pytest-django work. 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | addopts = --tb native 4 | filterwarnings = error 5 | -------------------------------------------------------------------------------- /sass_processor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | See PEP 386 (https://www.python.org/dev/peps/pep-0386/) 3 | 4 | Release logic: 5 | 1. Remove ".devX" from __version__ (below) 6 | 2. Remove ".devX" latest version in README.md / Changelog 7 | 3. git add sass_processor/__init__.py 8 | 4. git commit -m 'Bump to ' 9 | 5. git push 10 | 6. (assure that all tests pass) 11 | 7. git tag 12 | 8. git push --tags 13 | 9. python setup.py sdist upload 14 | 10. bump the version, append ".dev0" to __version__ 15 | 11. Add a new heading to README.md / Changelog, named ".dev" 16 | 12. git add sass_processor/__init__.py README.md 17 | 12. git commit -m 'Start with ' 18 | 13. git push 19 | """ 20 | 21 | __version__ = '1.4.2' 22 | -------------------------------------------------------------------------------- /sass_processor/apps.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | from django.apps import apps, AppConfig 4 | from django.conf import settings 5 | from django.contrib.staticfiles.finders import AppDirectoriesFinder 6 | 7 | 8 | APPS_INCLUDE_DIRS = [] 9 | 10 | class SassProcessorConfig(AppConfig): 11 | name = 'sass_processor' 12 | verbose_name = "Sass Processor" 13 | auto_include = getattr(settings, 'SASS_PROCESSOR_AUTO_INCLUDE', True) 14 | _pattern = re.compile(getattr(settings, 'SASS_PROCESSOR_INCLUDE_FILE_PATTERN', r'^_.+\.(scss|sass)$')) 15 | 16 | def ready(self): 17 | if self.auto_include: 18 | app_configs = apps.get_app_configs() 19 | for app_config in app_configs: 20 | static_dir = os.path.join(app_config.path, AppDirectoriesFinder.source_dir) 21 | if os.path.isdir(static_dir): 22 | self.traverse_tree(static_dir) 23 | 24 | @classmethod 25 | def traverse_tree(cls, static_dir): 26 | """traverse the static folders an look for at least one file ending in .scss/.sass""" 27 | for root, dirs, files in os.walk(static_dir): 28 | for filename in files: 29 | if cls._pattern.match(filename): 30 | APPS_INCLUDE_DIRS.append(static_dir) 31 | return 32 | -------------------------------------------------------------------------------- /sass_processor/finders.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.finders import BaseStorageFinder 2 | from .storage import SassFileStorage 3 | 4 | 5 | class CssFinder(BaseStorageFinder): 6 | """ 7 | Find static *.css files compiled on the fly using templatetag `{% sass_src "" %}` 8 | and stored in configured storage. 9 | """ 10 | storage = SassFileStorage() 11 | 12 | def list(self, ignore_patterns): 13 | """ 14 | Do not list the contents of the configured storages, since this has already been done by 15 | other finders. 16 | This prevents the warning ``Found another file with the destination path ...``, while 17 | issuing ``./manage.py collectstatic``. 18 | """ 19 | if False: 20 | yield 21 | -------------------------------------------------------------------------------- /sass_processor/jinja2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrief/django-sass-processor/98e3757bb42f6a46e53fe1fe2a882f04b417d801/sass_processor/jinja2/__init__.py -------------------------------------------------------------------------------- /sass_processor/jinja2/ext.py: -------------------------------------------------------------------------------- 1 | from jinja2 import nodes 2 | from jinja2.ext import Extension 3 | from sass_processor.processor import SassProcessor 4 | 5 | 6 | class SassSrc(Extension): 7 | tags = set(['sass_src']) 8 | 9 | def parse(self, parser): 10 | lineno = next(parser.stream).lineno 11 | path = parser.parse_expression() 12 | 13 | call = self.call_method( 14 | '_sass_src_support', [ 15 | path, 16 | nodes.Const(parser.filename) 17 | ] 18 | ) 19 | return nodes.Output( 20 | [call], 21 | lineno=lineno 22 | ) 23 | 24 | def _sass_src_support(self, path, source_file): 25 | sass_processor = SassProcessor(source_file) 26 | return SassProcessor.handle_simple(sass_processor(path)) 27 | -------------------------------------------------------------------------------- /sass_processor/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrief/django-sass-processor/98e3757bb42f6a46e53fe1fe2a882f04b417d801/sass_processor/management/__init__.py -------------------------------------------------------------------------------- /sass_processor/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrief/django-sass-processor/98e3757bb42f6a46e53fe1fe2a882f04b417d801/sass_processor/management/commands/__init__.py -------------------------------------------------------------------------------- /sass_processor/management/commands/compilescss.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import ast 4 | from importlib import import_module 5 | import sass 6 | from compressor.exceptions import TemplateDoesNotExist, TemplateSyntaxError 7 | from pathlib import Path 8 | 9 | from django.apps import apps 10 | from django.conf import settings 11 | from django.core.files.base import ContentFile 12 | from django.core.management.base import BaseCommand, CommandError 13 | from django.template import engines 14 | from django.template.base import Origin 15 | from django.template.loader import \ 16 | get_template # in order to preload template locations 17 | from django.utils.encoding import force_bytes 18 | from django.utils.translation import gettext_lazy as _ 19 | 20 | from sass_processor.apps import APPS_INCLUDE_DIRS 21 | from sass_processor.processor import SassProcessor 22 | from sass_processor.storage import SassFileStorage, find_file 23 | from sass_processor.templatetags.sass_tags import SassSrcNode 24 | from sass_processor.utils import get_custom_functions 25 | 26 | __all__ = ['get_template', 'Command'] 27 | 28 | 29 | class FuncCallVisitor(ast.NodeVisitor): 30 | 31 | def __init__(self, func_name): 32 | self.func_name = func_name 33 | self.sass_files = [] 34 | 35 | def visit_Call(self, node): 36 | try: 37 | if node.func.id == self.func_name: 38 | arg0 = dict((a, b) for a, b in ast.iter_fields(node))['args'][0] 39 | self.sass_files.append(getattr(arg0, arg0._fields[0])) 40 | except AttributeError: 41 | pass 42 | self.generic_visit(node) 43 | 44 | 45 | class Command(BaseCommand): 46 | help = "Compile SASS/SCSS into CSS outside of the request/response cycle" 47 | storage = SassFileStorage() 48 | 49 | def __init__(self): 50 | self.parser = None 51 | self.template_exts = getattr(settings, 'SASS_TEMPLATE_EXTS', ['.html']) 52 | self.sass_output_style = getattr(settings, 'SASS_OUTPUT_STYLE', 53 | 'nested' if settings.DEBUG else 'compressed') 54 | self.use_storage = False 55 | super().__init__() 56 | 57 | def add_arguments(self, parser): 58 | parser.add_argument( 59 | '--delete-files', 60 | action='store_true', 61 | dest='delete_files', 62 | default=False, 63 | help=_("Delete generated `*.css` files instead of creating them.") 64 | ) 65 | parser.add_argument( 66 | '--use-storage', 67 | action='store_true', 68 | dest='use_storage', 69 | default=False, 70 | help=_("Store resulting .css in configured storage. " 71 | "Default: store each css side-by-side with .scss.") 72 | ) 73 | parser.add_argument( 74 | '--engine', 75 | dest='engine', 76 | default='django', 77 | help=_("Set templating engine used (django, jinja2). Default: django.") 78 | ) 79 | parser.add_argument( 80 | '--sass-precision', 81 | dest='sass_precision', 82 | type=int, 83 | help=_( 84 | "Set the precision for numeric computations in the SASS processor. Default: settings.SASS_PRECISION.") 85 | ) 86 | 87 | def get_loaders(self): 88 | template_source_loaders = [] 89 | for e in engines.all(): 90 | if hasattr(e, 'engine'): 91 | template_source_loaders.extend( 92 | e.engine.get_template_loaders( 93 | e.engine.loaders 94 | ) 95 | ) 96 | loaders = [] 97 | # If template loader is CachedTemplateLoader, return the loaders 98 | # that it wraps around. So if we have 99 | # TEMPLATE_LOADERS = ( 100 | # ('django.template.loaders.cached.Loader', ( 101 | # 'django.template.loaders.filesystem.Loader', 102 | # 'django.template.loaders.app_directories.Loader', 103 | # )), 104 | # ) 105 | # The loaders will return django.template.loaders.filesystem.Loader 106 | # and django.template.loaders.app_directories.Loader 107 | # The cached Loader and similar ones include a 'loaders' attribute 108 | # so we look for that. 109 | for loader in template_source_loaders: 110 | if hasattr(loader, 'loaders'): 111 | loaders.extend(loader.loaders) 112 | else: 113 | loaders.append(loader) 114 | return loaders 115 | 116 | def get_parser(self, engine): 117 | if engine == 'jinja2': 118 | from compressor.offline.jinja2 import Jinja2Parser 119 | env = settings.COMPRESS_JINJA2_GET_ENVIRONMENT() 120 | parser = Jinja2Parser(charset='utf-8', env=env) 121 | elif engine == 'django': 122 | from compressor.offline.django import DjangoParser 123 | parser = DjangoParser(charset='utf-8') 124 | else: 125 | raise CommandError( 126 | "Invalid templating engine '{engine}' specified.".format( 127 | engine=engine 128 | ) 129 | ) 130 | return parser 131 | 132 | def handle(self, *args, **options): 133 | self.verbosity = int(options['verbosity']) 134 | self.delete_files = options['delete_files'] 135 | self.use_storage = options['use_storage'] 136 | 137 | engines = [e.strip() for e in options.get('engines', [])] or ['django'] 138 | for engine in engines: 139 | self.parser = self.get_parser(engine) 140 | try: 141 | self.sass_precision = int(options['sass_precision'] or settings.SASS_PRECISION) 142 | except (AttributeError, TypeError, ValueError): 143 | self.sass_precision = None 144 | 145 | self.processed_files = [] 146 | 147 | # find all Python files making up this project; They might invoke `sass_processor` 148 | for py_source in self.find_sources(): 149 | if self.verbosity > 1: 150 | self.stdout.write("Parsing file: {}".format(py_source)) 151 | elif self.verbosity == 1: 152 | self.stdout.write(".", ending="") 153 | try: 154 | self.parse_source(py_source) 155 | except (SyntaxError, IndentationError) as exc: 156 | msg = "Syntax error encountered processing {0}: {1}\nAborting compilation." 157 | self.stderr.write(msg.format(py_source, exc)) 158 | raise 159 | 160 | # find all Django/Jinja2 templates making up this project; They might invoke `sass_src` 161 | templates = self.find_templates() 162 | for template_name in templates: 163 | self.parse_template(template_name) 164 | if self.verbosity > 0: 165 | self.stdout.write(".", ending="") 166 | 167 | # summarize what has been done 168 | if self.verbosity > 0: 169 | self.stdout.write("") 170 | if self.delete_files: 171 | msg = "Successfully deleted {0} previously generated `*.css` files." 172 | self.stdout.write(msg.format(len(self.processed_files))) 173 | else: 174 | msg = "Successfully compiled {0} referred SASS/SCSS files." 175 | self.stdout.write(msg.format(len(self.processed_files))) 176 | 177 | def find_sources(self): 178 | """ 179 | Look for Python sources available for the current configuration. 180 | """ 181 | app_config = apps.get_app_config('sass_processor') 182 | if app_config.auto_include: 183 | app_configs = apps.get_app_configs() 184 | for app_config in app_configs: 185 | ignore_dirs = [] 186 | for root, dirs, files in os.walk(app_config.path): 187 | if [True for idir in ignore_dirs if root.startswith(idir)]: 188 | continue 189 | if '__init__.py' not in files: 190 | ignore_dirs.append(root) 191 | continue 192 | for filename in files: 193 | basename, ext = os.path.splitext(filename) 194 | if ext != '.py': 195 | continue 196 | yield os.path.abspath(os.path.join(root, filename)) 197 | 198 | def parse_source(self, filename): 199 | """ 200 | Extract the statements from the given file, look for function calls 201 | `sass_processor(scss_file)` and compile the filename into CSS. 202 | """ 203 | callvisitor = FuncCallVisitor('sass_processor') 204 | tree = ast.parse(Path(filename).read_bytes()) 205 | callvisitor.visit(tree) 206 | for sass_fileurl in callvisitor.sass_files: 207 | sass_filename = find_file(sass_fileurl) 208 | if not sass_filename or sass_filename in self.processed_files: 209 | continue 210 | if self.delete_files: 211 | self.delete_file(sass_filename, sass_fileurl) 212 | else: 213 | self.compile_sass(sass_filename, sass_fileurl) 214 | 215 | def find_templates(self): 216 | """ 217 | Look for templates and extract the nodes containing the SASS file. 218 | """ 219 | paths = set() 220 | for loader in self.get_loaders(): 221 | try: 222 | module = import_module(loader.__module__) 223 | get_template_sources = getattr( 224 | module, 'get_template_sources', loader.get_template_sources) 225 | template_sources = get_template_sources('') 226 | paths.update([t.name if isinstance(t, Origin) else t for t in template_sources]) 227 | except (ImportError, AttributeError): 228 | pass 229 | if not paths: 230 | raise CommandError( 231 | "No template paths found. None of the configured template loaders provided template paths") 232 | templates = set() 233 | for path in paths: 234 | for root, _, files in os.walk(str(path)): 235 | templates.update(os.path.join(root, name) 236 | for name in files if not name.startswith('.') and 237 | any(name.endswith(ext) for ext in self.template_exts)) 238 | if not templates: 239 | raise CommandError( 240 | "No templates found. Make sure your TEMPLATE_LOADERS and TEMPLATE_DIRS settings are correct.") 241 | return templates 242 | 243 | def parse_template(self, template_name): 244 | try: 245 | template = self.parser.parse(template_name) 246 | except IOError: # unreadable file -> ignore 247 | if self.verbosity > 0: 248 | self.stderr.write("\nUnreadable template at: {}".format(template_name)) 249 | return 250 | except TemplateSyntaxError as e: # broken template -> ignore 251 | if self.verbosity > 0: 252 | self.stderr.write("\nInvalid template {}: {}".format(template_name, e)) 253 | return 254 | except TemplateDoesNotExist: # non existent template -> ignore 255 | if self.verbosity > 0: 256 | self.stderr.write("\nNon-existent template at: {}".format(template_name)) 257 | return 258 | except UnicodeDecodeError: 259 | if self.verbosity > 0: 260 | self.stderr.write( 261 | "\nUnicodeDecodeError while trying to read template {}".format(template_name)) 262 | try: 263 | nodes = list(self.walk_nodes(template, original=template)) 264 | except Exception as e: 265 | # Could be an error in some base template 266 | if self.verbosity > 0: 267 | self.stderr.write("\nError parsing template {}: {}".format(template_name, e)) 268 | else: 269 | for node in nodes: 270 | sass_filename = find_file(node.path) 271 | if not sass_filename or sass_filename in self.processed_files: 272 | continue 273 | if self.delete_files: 274 | self.delete_file(sass_filename, node.path) 275 | else: 276 | self.compile_sass(sass_filename, node.path) 277 | 278 | def compile_sass(self, sass_filename, sass_fileurl): 279 | """ 280 | Compile the given SASS file into CSS 281 | """ 282 | compile_kwargs = { 283 | 'filename': sass_filename, 284 | 'include_paths': SassProcessor.include_paths + APPS_INCLUDE_DIRS, 285 | 'custom_functions': get_custom_functions(), 286 | } 287 | if self.sass_precision: 288 | compile_kwargs['precision'] = self.sass_precision 289 | if self.sass_output_style: 290 | compile_kwargs['output_style'] = self.sass_output_style 291 | content = sass.compile(**compile_kwargs) 292 | self.save_to_destination(content, sass_filename, sass_fileurl) 293 | self.processed_files.append(sass_filename) 294 | if self.verbosity > 1: 295 | self.stdout.write("Compiled SASS/SCSS file: '{0}'\n".format(sass_filename)) 296 | 297 | def delete_file(self, sass_filename, sass_fileurl): 298 | """ 299 | Delete a *.css file, but only if it has been generated through a SASS/SCSS file. 300 | """ 301 | if self.use_storage: 302 | destpath = os.path.splitext(sass_fileurl)[0] + '.css' 303 | if self.storage.exists(destpath): 304 | self.storage.delete(destpath) 305 | else: 306 | return 307 | else: 308 | destpath = os.path.splitext(sass_filename)[0] + '.css' 309 | if os.path.isfile(destpath): 310 | os.remove(destpath) 311 | else: 312 | return 313 | self.processed_files.append(sass_filename) 314 | if self.verbosity > 1: 315 | self.stdout.write("Deleted '{0}'\n".format(destpath)) 316 | 317 | def save_to_destination(self, content, sass_filename, sass_fileurl): 318 | if self.use_storage: 319 | basename, _ = os.path.splitext(sass_fileurl) 320 | destpath = basename + '.css' 321 | if self.storage.exists(destpath): 322 | self.storage.delete(destpath) 323 | self.storage.save(destpath, ContentFile(content)) 324 | else: 325 | basename, _ = os.path.splitext(sass_filename) 326 | destpath = basename + '.css' 327 | with open(destpath, 'wb') as fh: 328 | fh.write(force_bytes(content)) 329 | 330 | def walk_nodes(self, node, original): 331 | """ 332 | Iterate over the nodes recursively yielding the templatetag 'sass_src' 333 | """ 334 | try: 335 | # try with django-compressor<2.1 336 | nodelist = self.parser.get_nodelist(node, original=original) 337 | except TypeError: 338 | nodelist = self.parser.get_nodelist(node, original=original, context=None) 339 | for node in nodelist: 340 | if isinstance(node, SassSrcNode): 341 | if node.is_sass: 342 | yield node 343 | else: 344 | for node in self.walk_nodes(node, original=original): 345 | yield node 346 | -------------------------------------------------------------------------------- /sass_processor/processor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import subprocess 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.core.files.base import ContentFile 9 | from django.template import Context 10 | from django.utils.encoding import force_bytes 11 | 12 | from sass_processor.utils import get_custom_functions 13 | 14 | from .storage import SassFileStorage, find_file 15 | from .apps import APPS_INCLUDE_DIRS 16 | 17 | try: 18 | import sass 19 | except ImportError: 20 | sass = None 21 | 22 | logger = logging.getLogger('sass-processor') 23 | 24 | 25 | class SassProcessor: 26 | source_storage = SassFileStorage() 27 | include_paths = [str(ip) for ip in getattr(settings, 'SASS_PROCESSOR_INCLUDE_DIRS', [])] 28 | try: 29 | sass_precision = int(settings.SASS_PRECISION) 30 | except (AttributeError, TypeError, ValueError): 31 | sass_precision = None 32 | sass_output_style = getattr( 33 | settings, 34 | 'SASS_OUTPUT_STYLE', 35 | 'nested' if settings.DEBUG else 'compressed') 36 | processor_enabled = getattr(settings, 'SASS_PROCESSOR_ENABLED', settings.DEBUG) 37 | fail_silently = getattr(settings, 'SASS_PROCESSOR_FAIL_SILENTLY', not settings.DEBUG) 38 | sass_extensions = ('.scss', '.sass') 39 | node_npx_path = getattr(settings, 'NODE_NPX_PATH', 'npx') 40 | 41 | def __init__(self, path=None): 42 | self._path = path 43 | nmd = [d[1] for d in getattr(settings, 'STATICFILES_DIRS', []) 44 | if isinstance(d, (list, tuple)) and d[0] == 'node_modules'] 45 | self.node_modules_dir = str(nmd[0]) if len(nmd) else None 46 | 47 | def __call__(self, path): 48 | basename, ext = os.path.splitext(path) 49 | filename = find_file(path) 50 | if filename is None: 51 | raise FileNotFoundError("Unable to locate file {path}".format(path=path)) 52 | 53 | if ext not in self.sass_extensions: 54 | # return the given path, since it ends neither in `.scss` nor in `.sass` 55 | return path 56 | 57 | # compare timestamp of sourcemap file with all its dependencies, and check if we must recompile 58 | css_filename = basename + '.css' 59 | if not self.processor_enabled: 60 | return css_filename 61 | sourcemap_filename = css_filename + '.map' 62 | base = os.path.dirname(filename) 63 | if self.source_storage.exists(css_filename) and self.is_latest(sourcemap_filename, base): 64 | return css_filename 65 | 66 | # with offline compilation, raise an error, if css file could not be found. 67 | if sass is None: 68 | msg = "Offline compiled file `{}` is missing and libsass has not been installed." 69 | raise ImproperlyConfigured(msg.format(css_filename)) 70 | 71 | # otherwise compile the SASS/SCSS file into .css and store it 72 | filename_map = filename.replace(ext, '.css.map') 73 | compile_kwargs = { 74 | 'filename': filename, 75 | 'source_map_filename': filename_map, 76 | 'include_paths': self.include_paths + APPS_INCLUDE_DIRS, 77 | 'custom_functions': get_custom_functions(), 78 | } 79 | if self.sass_precision: 80 | compile_kwargs['precision'] = self.sass_precision 81 | if self.sass_output_style: 82 | compile_kwargs['output_style'] = self.sass_output_style 83 | try: 84 | content, sourcemap = (force_bytes(output) for output in sass.compile(**compile_kwargs)) 85 | except sass.CompileError as exc: 86 | if self.fail_silently: 87 | content, sourcemap = force_bytes(exc), None 88 | logger.error(exc) 89 | else: 90 | raise exc 91 | 92 | # autoprefix CSS files using postcss in external JavaScript process 93 | if self.node_npx_path and os.path.isdir(self.node_modules_dir or ''): 94 | os.environ['NODE_PATH'] = self.node_modules_dir 95 | try: 96 | options = [self.node_npx_path, 'postcss', '--use', 'autoprefixer'] 97 | if not settings.DEBUG: 98 | options.append('--no-map') 99 | proc = subprocess.Popen(options, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 100 | proc.stdin.write(content) 101 | proc.stdin.close() 102 | autoprefixed_content = proc.stdout.read() 103 | proc.wait() 104 | except (FileNotFoundError, BrokenPipeError) as exc: 105 | logger.warning("Unable to postcss {}. Reason: {}".format(filename, exc)) 106 | else: 107 | if len(autoprefixed_content) >= len(content): 108 | content = autoprefixed_content 109 | 110 | if self.source_storage.exists(css_filename): 111 | self.source_storage.delete(css_filename) 112 | self.source_storage.save(css_filename, ContentFile(content)) 113 | if self.source_storage.exists(sourcemap_filename): 114 | self.source_storage.delete(sourcemap_filename) 115 | if sourcemap: 116 | self.source_storage.save(sourcemap_filename, ContentFile(sourcemap)) 117 | return css_filename 118 | 119 | def resolve_path(self, context=None): 120 | if context is None: 121 | context = Context() 122 | return self._path.resolve(context) 123 | 124 | def is_sass(self): 125 | _, ext = os.path.splitext(self.resolve_path()) 126 | return ext in self.sass_extensions 127 | 128 | def is_latest(self, sourcemap_file, base): 129 | if not self.source_storage.exists(sourcemap_file): 130 | return False 131 | sourcemap_mtime = self.source_storage.get_modified_time(sourcemap_file).timestamp() 132 | with self.source_storage.open(sourcemap_file, 'r') as fp: 133 | sourcemap = json.load(fp) 134 | for srcfilename in sourcemap.get('sources'): 135 | srcfilename = os.path.join(base, srcfilename) 136 | if not os.path.isfile(srcfilename) or os.stat(srcfilename).st_mtime > sourcemap_mtime: 137 | # at least one of the source is younger that the sourcemap referring it 138 | return False 139 | return True 140 | 141 | @classmethod 142 | def handle_simple(cls, path): 143 | return cls.source_storage.url(path) 144 | 145 | 146 | _sass_processor = SassProcessor() 147 | def sass_processor(filename): 148 | path = _sass_processor(filename) 149 | return SassProcessor.handle_simple(path) 150 | -------------------------------------------------------------------------------- /sass_processor/storage.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | from django.conf import settings 3 | from django.contrib.staticfiles.finders import get_finders 4 | from django.core.files.storage import FileSystemStorage 5 | from django.utils.functional import LazyObject 6 | from django.utils.module_loading import import_string 7 | 8 | 9 | class SassFileStorage(LazyObject): 10 | def _setup(self): 11 | version_parts = VERSION[:2] 12 | if version_parts[0] > 4 or (version_parts[0] == 4 and version_parts[1] >= 2): 13 | staticfiles_storage_backend = settings.STORAGES.get("staticfiles", {}).get("BACKEND") 14 | sass_processor_storage = settings.STORAGES.get("sass_processor", {}) 15 | 16 | storage_path = sass_processor_storage.get("BACKEND") or staticfiles_storage_backend 17 | storage_options = sass_processor_storage.get("OPTIONS") or {} 18 | storage_class = import_string(storage_path) 19 | else: 20 | from django.core.files.storage import get_storage_class 21 | staticfiles_storage_backend = settings.STATICFILES_STORAGE 22 | storage_path = getattr(settings, 'SASS_PROCESSOR_STORAGE', staticfiles_storage_backend) 23 | storage_options = getattr(settings, 'SASS_PROCESSOR_STORAGE_OPTIONS', {}) 24 | storage_class = get_storage_class(storage_path) 25 | 26 | storage_options["ROOT"] = getattr(settings, 'SASS_PROCESSOR_ROOT', settings.STATIC_ROOT) 27 | 28 | if storage_path == staticfiles_storage_backend and issubclass(storage_class, FileSystemStorage): 29 | storage_options['location'] = storage_options.pop("ROOT", None) or settings.STATIC_ROOT 30 | storage_options['base_url'] = settings.STATIC_URL 31 | 32 | self._wrapped = storage_class(**storage_options) 33 | 34 | 35 | def find_file(path): 36 | for finder in get_finders(): 37 | result = finder.find(path) 38 | if result: 39 | return result 40 | -------------------------------------------------------------------------------- /sass_processor/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrief/django-sass-processor/98e3757bb42f6a46e53fe1fe2a882f04b417d801/sass_processor/templatetags/__init__.py -------------------------------------------------------------------------------- /sass_processor/templatetags/sass_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.template.base import Node, TemplateSyntaxError 3 | from sass_processor.processor import SassProcessor 4 | 5 | try: 6 | FileNotFoundError 7 | except NameError: 8 | FileNotFoundError = IOError 9 | 10 | register = Library() 11 | 12 | 13 | class SassSrcNode(Node): 14 | def __init__(self, path): 15 | self.sass_processor = SassProcessor(path) 16 | 17 | @classmethod 18 | def handle_token(cls, parser, token): 19 | bits = token.split_contents() 20 | if len(bits) != 2: 21 | raise TemplateSyntaxError("'{0}' takes a URL to a CSS file as its only argument".format(*bits)) 22 | path = parser.compile_filter(bits[1]) 23 | return cls(path) 24 | 25 | @property 26 | def path(self): 27 | return self.sass_processor.resolve_path() 28 | 29 | @property 30 | def is_sass(self): 31 | return self.sass_processor.is_sass() 32 | 33 | def render(self, context): 34 | try: 35 | path = self.sass_processor(self.sass_processor.resolve_path(context)) 36 | except AttributeError as e: 37 | msg = "No sass/scss file specified while rendering tag 'sass_src' in template {} ({})" 38 | raise TemplateSyntaxError(msg.format(context.template_name, e)) 39 | except FileNotFoundError as e: 40 | msg = str(e) + " while rendering tag 'sass_src' in template {}" 41 | raise TemplateSyntaxError(msg.format(context.template_name)) 42 | return SassProcessor.handle_simple(path) 43 | 44 | 45 | @register.tag(name='sass_src') 46 | def render_sass_src(parser, token): 47 | return SassSrcNode.handle_token(parser, token) 48 | -------------------------------------------------------------------------------- /sass_processor/types.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from sass import SassNumber as _SassNumber, SassColor, SassList, SassMap 3 | 4 | __all__ = ['SassNumber', 'SassColor', 'SassList', 'SassMap'] 5 | 6 | 7 | def SassNumber(value): 8 | if isinstance(value, (int, float, Decimal)): 9 | return str(_SassNumber(value, type(value).__name__.encode()).value) 10 | return value 11 | -------------------------------------------------------------------------------- /sass_processor/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.conf import settings 4 | from django.template import TemplateSyntaxError 5 | from django.utils.module_loading import import_string 6 | 7 | try: 8 | import sass 9 | except ImportError: 10 | sass = None 11 | 12 | 13 | def get_custom_functions(): 14 | """ 15 | Return a dict of function names, to be used from inside SASS 16 | """ 17 | def get_setting(*args): 18 | try: 19 | return getattr(settings, args[0]) 20 | except AttributeError as e: 21 | raise TemplateSyntaxError(str(e)) 22 | 23 | if hasattr(get_custom_functions, '_custom_functions'): 24 | return get_custom_functions._custom_functions 25 | get_custom_functions._custom_functions = {sass.SassFunction('get-setting', ('key',), get_setting)} 26 | for name, func in getattr(settings, 'SASS_PROCESSOR_CUSTOM_FUNCTIONS', {}).items(): 27 | try: 28 | if isinstance(func, str): 29 | func = import_string(func) 30 | except Exception as e: 31 | raise TemplateSyntaxError(str(e)) 32 | else: 33 | if not inspect.isfunction(func): 34 | raise TemplateSyntaxError("{} is not a Python function".format(func)) 35 | func_args = inspect.getfullargspec(func).args 36 | sass_func = sass.SassFunction(name, func_args, func) 37 | get_custom_functions._custom_functions.add(sass_func) 38 | return get_custom_functions._custom_functions 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-sass-processor 3 | version = attr: sass_processor.__version__ 4 | description = SASS processor to compile SCSS files into *.css, while rendering, or offline. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = Jacob Rief 8 | author_email = jacob.rief@gmail.com 9 | url = https://github.com/jrief/django-sass-processor 10 | license = MIT 11 | license_file = LICENSE-MIT 12 | keywords = django, sass 13 | classifiers = 14 | Development Status :: 5 - Production/Stable 15 | Environment :: Web Environment 16 | Framework :: Django 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: MIT License 19 | Operating System :: OS Independent 20 | Programming Language :: Python 21 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Programming Language :: Python :: 3.10 25 | Programming Language :: Python :: 3.11 26 | Programming Language :: Python :: 3.12 27 | Programming Language :: Python :: 3.13 28 | Framework :: Django :: 3.2 29 | Framework :: Django :: 4.2 30 | Framework :: Django :: 5.0 31 | Framework :: Django :: 5.1 32 | Framework :: Django :: 5.2 33 | 34 | [options] 35 | packages = find: 36 | zip_safe = False 37 | 38 | [options.packages.find] 39 | exclude = tests 40 | 41 | [options.extras_require] 42 | management-command = django-compressor>=2.4 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from sass_processor.types import SassNumber, SassColor 2 | 3 | def get_width(): 4 | return SassNumber(5) 5 | 6 | def get_margins(top, right, bottom, left): 7 | return "{}px {}px {}px {}px".format(top.value, right.value, bottom.value, left.value) 8 | 9 | def get_plain_color(r, g, b): 10 | return SassColor(r.value, g.value, b.value, 1) 11 | -------------------------------------------------------------------------------- /tests/jinja2.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Environment 2 | 3 | 4 | def environment(**kwargs): 5 | extensions = [] if 'extensions' not in kwargs else kwargs['extensions'] 6 | extensions.append('sass_processor.jinja2.ext.SassSrc') 7 | kwargs['extensions'] = extensions 8 | 9 | return Environment(**kwargs) 10 | -------------------------------------------------------------------------------- /tests/jinja2/tests/jinja2.html: -------------------------------------------------------------------------------- 1 | {% sass_src 'tests/css/main.scss' %} 2 | -------------------------------------------------------------------------------- /tests/jinja2/tests/jinja2_variable.html: -------------------------------------------------------------------------------- 1 | {% set source = 'tests/css/main.scss' %} 2 | {% sass_src source %} 3 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools 2 | libsass 3 | pytest 4 | pytest-django 5 | jinja2 6 | django-compressor 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from tests.jinja2 import environment 4 | 5 | SITE_ID = 1 6 | 7 | DATABASE_ENGINE = 'sqlite3' 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | 'NAME': ':memory:', 13 | } 14 | } 15 | 16 | INSTALLED_APPS = [ 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sites', 19 | 'django.contrib.auth', 20 | 'django.contrib.admin', 21 | 'django.contrib.staticfiles', 22 | 'sass_processor', 23 | 'tests', 24 | ] 25 | 26 | TEMPLATES = [ 27 | { 28 | 'NAME': 'django', 29 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 30 | 'APP_DIRS': True, 31 | 'OPTIONS': { 32 | 'context_processors': [ 33 | 'django.contrib.auth.context_processors.auth', 34 | 'django.template.context_processors.debug', 35 | 'django.template.context_processors.i18n', 36 | 'django.template.context_processors.media', 37 | 'django.template.context_processors.static', 38 | 'django.template.context_processors.tz', 39 | 'django.contrib.messages.context_processors.messages', 40 | ], 41 | }, 42 | }, 43 | { 44 | 'NAME': 'jinja2', 45 | 'BACKEND': 'django.template.backends.jinja2.Jinja2', 46 | 'APP_DIRS': True, 47 | 'OPTIONS': { 48 | 'environment': 'tests.jinja2.environment' 49 | }, 50 | } 51 | ] 52 | COMPRESS_JINJA2_GET_ENVIRONMENT = environment 53 | 54 | MIDDLEWARE_CLASSES = ( 55 | 'django.contrib.sessions.middleware.SessionMiddleware', 56 | 'django.middleware.common.CommonMiddleware', 57 | 'django.middleware.csrf.CsrfViewMiddleware', 58 | #'django.contrib.auth.middleware.AuthenticationMiddleware', 59 | ) 60 | 61 | USE_TZ = True 62 | 63 | SECRET_KEY = 'secret' 64 | 65 | STATIC_URL = '/static/' 66 | 67 | PROJECT_ROOT = os.path.abspath(os.path.join(__file__, os.path.pardir)) 68 | 69 | STATICFILES_FINDERS = [ 70 | 'django.contrib.staticfiles.finders.FileSystemFinder', 71 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 72 | 'sass_processor.finders.CssFinder', 73 | ] 74 | 75 | STATICFILES_DIRS = [ 76 | os.path.join(PROJECT_ROOT, 'static'), 77 | ] 78 | 79 | SASS_PROCESSOR_ENABLED = True 80 | 81 | SASS_PROCESSOR_CUSTOM_FUNCTIONS = { 82 | 'get-width': 'tests.get_width', 83 | 'get-margins': 'tests.get_margins', 84 | 'get-plain-color': 'tests.get_plain_color', 85 | } 86 | 87 | SASS_BLUE_COLOR = '#0000ff' 88 | 89 | 90 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), "tmpstatic") 91 | -------------------------------------------------------------------------------- /tests/static/tests/css/_redbox.scss: -------------------------------------------------------------------------------- 1 | .redbox { 2 | background-color: #ff0000; 3 | &:hover { 4 | color: #000000; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/static/tests/css/bluebox.scss: -------------------------------------------------------------------------------- 1 | .bluebox { 2 | background-color: get-setting(SASS_BLUE_COLOR); 3 | margin: get-margins(10, 5, 20, 15); 4 | color: get-plain-color(250, 10, 120); 5 | } 6 | -------------------------------------------------------------------------------- /tests/static/tests/css/main.scss: -------------------------------------------------------------------------------- 1 | #main p { 2 | color: #00ff00; 3 | width: 97%; 4 | 5 | @import "redbox"; 6 | } 7 | -------------------------------------------------------------------------------- /tests/templates/tests/django.html: -------------------------------------------------------------------------------- 1 | {% load sass_tags %}{% sass_src 'tests/css/main.scss' %} 2 | -------------------------------------------------------------------------------- /tests/test_sass_processor.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import os 3 | import shutil 4 | from datetime import datetime 5 | 6 | from django.conf import settings 7 | from django.core.management import call_command 8 | from django.template.loader import get_template 9 | from django.test import TestCase, override_settings 10 | 11 | 12 | class SassProcessorTest(TestCase): 13 | 14 | def setUp(self): 15 | super(SassProcessorTest, self).setUp() 16 | try: 17 | os.mkdir(settings.STATIC_ROOT) 18 | except OSError: 19 | pass 20 | 21 | def tearDown(self): 22 | shutil.rmtree(settings.STATIC_ROOT) 23 | 24 | def assert_sass_src_engine(self, template_name, engine): 25 | template = get_template( 26 | template_name=template_name, 27 | using=engine 28 | ) 29 | # Strip the line breaks. 30 | template_content = template.render({}).strip() 31 | self.assertEqual('/static/tests/css/main.css', template_content) 32 | 33 | css_file = os.path.join(settings.STATIC_ROOT, 'tests/css/main.css') 34 | self.assertTrue(os.path.exists(css_file)) 35 | with open(css_file, 'r') as f: 36 | output = f.read() 37 | expected = "#main p{color:#00ff00;width:97%}#main p .redbox{background-color:#ff0000}#main p .redbox:hover{color:#000000}\n\n/*# sourceMappingURL=main.css.map */" 38 | self.assertEqual(expected, output) 39 | 40 | # check if compilation is skipped file for a second invocation of `sass_src` 41 | timestamp = os.path.getmtime(css_file) 42 | template.render({}) 43 | self.assertEqual(timestamp, os.path.getmtime(css_file)) 44 | 45 | # removing `main.css.map` should trigger a recompilation 46 | os.remove(css_file + '.map') 47 | template.render({}) 48 | self.assertTrue(os.path.exists(css_file + '.map')) 49 | 50 | # if `main.scss` is newer than `main.css`, recompile everything 51 | longago = calendar.timegm(datetime(2017, 1, 1).timetuple()) 52 | os.utime(css_file, (longago, longago)) 53 | template.render({}) 54 | self.assertGreater(timestamp, os.path.getmtime(css_file)) 55 | 56 | def test_sass_src_django(self): 57 | self.assert_sass_src_engine( 58 | template_name='tests/django.html', 59 | engine='django' 60 | ) 61 | 62 | def test_sass_src_jinja2(self): 63 | self.assert_sass_src_engine( 64 | template_name='tests/jinja2.html', 65 | engine='jinja2' 66 | ) 67 | 68 | def test_sass_src_jinja2_variable(self): 69 | self.assert_sass_src_engine( 70 | template_name='tests/jinja2_variable.html', 71 | engine='jinja2' 72 | ) 73 | 74 | def test_sass_processor(self): 75 | from sass_processor.processor import sass_processor 76 | 77 | css_file = sass_processor('tests/css/bluebox.scss') 78 | self.assertEqual('/static/tests/css/bluebox.css', css_file) 79 | css_file = os.path.join(settings.STATIC_ROOT, 'tests/css/bluebox.css') 80 | self.assertTrue(os.path.exists(css_file)) 81 | with open(css_file, 'r') as f: 82 | output = f.read() 83 | expected = '.bluebox{background-color:#0000ff;margin:10.0px 5.0px 20.0px 15.0px;color:#fa0a78}\n\n/*# sourceMappingURL=bluebox.css.map */' 84 | self.assertEqual(expected, output) 85 | 86 | def assert_management_command(self, **kwargs): 87 | call_command( 88 | 'compilescss', 89 | **kwargs 90 | ) 91 | if kwargs.get('use_storage', False): 92 | css_file = os.path.join(settings.STATIC_ROOT, 'tests/css/main.css') 93 | else: 94 | css_file = os.path.join(settings.PROJECT_ROOT, 'static/tests/css/main.css') 95 | with open(css_file, 'r') as f: 96 | output = f.read() 97 | expected = '#main p{color:#00ff00;width:97%}#main p .redbox{background-color:#ff0000}#main p .redbox:hover{color:#000000}\n' 98 | self.assertEqual(expected, output) 99 | self.assertFalse(os.path.exists(css_file + '.map')) 100 | 101 | if not kwargs.get('use_storage', False): 102 | call_command('compilescss', delete_files=True) 103 | self.assertFalse(os.path.exists(css_file)) 104 | 105 | @override_settings(DEBUG=False) 106 | def test_management_command_django(self): 107 | self.assert_management_command( 108 | engine='django' 109 | ) 110 | 111 | @override_settings(DEBUG=False) 112 | def test_management_command_jinja2(self): 113 | self.assert_management_command( 114 | engine='jinja2' 115 | ) 116 | 117 | @override_settings(DEBUG=False) 118 | def test_management_command_multiple(self): 119 | self.assert_management_command( 120 | engine=['jinja2', 'django'] 121 | ) 122 | 123 | @override_settings(DEBUG=False) 124 | def test_use_storage_django(self): 125 | self.assert_management_command( 126 | engine='django', 127 | use_storage=True 128 | ) 129 | 130 | @override_settings(DEBUG=False) 131 | def test_use_storage_jinja2(self): 132 | self.assert_management_command( 133 | engine='jinja2', 134 | use_storage=True 135 | ) 136 | 137 | @override_settings(DEBUG=False) 138 | def test_use_storage_multiple(self): 139 | self.assert_management_command( 140 | engine=['jinja2', 'django'], 141 | use_storage=True 142 | ) 143 | --------------------------------------------------------------------------------