├── .editorconfig ├── .gitattributes ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── azure-pipelines.yml ├── ci └── compare-codecov.ps1 ├── django_sass ├── __init__.py ├── apps.py └── management │ └── commands │ └── sass.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── testproject ├── app1 ├── __init__.py ├── apps.py └── static │ └── app1 │ └── scss │ └── _include.scss ├── app2 ├── __init__.py ├── apps.py └── static │ └── app2 │ └── scss │ ├── _samedir.scss │ ├── subdir │ └── _subdir.scss │ └── test.scss ├── app3 ├── __init__.py ├── apps.py └── static │ └── app3 │ └── sass │ └── indent_test.sass ├── manage.py ├── requirements.txt ├── testproject ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py └── tests.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.{html,css,js,json,xml,yaml,yml}] 14 | indent_size = 2 15 | 16 | [*.{md,ps1,sh,py,rst}] 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Explicitly declare text files that should always be normalized and converted 2 | # to unix line endings, to reduce cross-platform development issues. 3 | *.py text eol=lf 4 | *.html text eol=lf 5 | *.js text eol=lf 6 | *.css text eol=lf 7 | *.json text eol=lf 8 | *.md text eol=lf 9 | *.rst text eol=lf 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io, modified by CodeRed. 2 | 3 | ####################################### 4 | ### Editors 5 | ####################################### 6 | 7 | 8 | ### Emacs ### 9 | 10 | # -*- mode: gitignore; -*- 11 | *~ 12 | \#*\# 13 | /.emacs.desktop 14 | /.emacs.desktop.lock 15 | *.elc 16 | auto-save-list 17 | tramp 18 | .\#* 19 | 20 | # Org-mode 21 | .org-id-locations 22 | *_archive 23 | 24 | # flymake-mode 25 | *_flymake.* 26 | 27 | # eshell files 28 | /eshell/history 29 | /eshell/lastdir 30 | 31 | # elpa packages 32 | /elpa/ 33 | 34 | # reftex files 35 | *.rel 36 | 37 | # Flycheck 38 | flycheck_*.el 39 | 40 | # server auth directory 41 | /server/ 42 | 43 | # projectiles files 44 | .projectile 45 | 46 | # directory configuration 47 | .dir-locals.el 48 | 49 | # network security 50 | /network-security.data 51 | 52 | 53 | ### KomodoEdit ### 54 | 55 | *.komodoproject 56 | .komodotools 57 | 58 | 59 | ### PyCharm ### 60 | 61 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 62 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 63 | 64 | # User-specific stuff 65 | .idea/**/workspace.xml 66 | .idea/**/tasks.xml 67 | .idea/**/usage.statistics.xml 68 | .idea/**/dictionaries 69 | .idea/**/shelf 70 | 71 | # Generated files 72 | .idea/**/contentModel.xml 73 | 74 | # Sensitive or high-churn files 75 | .idea/**/dataSources/ 76 | .idea/**/dataSources.ids 77 | .idea/**/dataSources.local.xml 78 | .idea/**/sqlDataSources.xml 79 | .idea/**/dynamic.xml 80 | .idea/**/uiDesigner.xml 81 | .idea/**/dbnavigator.xml 82 | 83 | # Gradle 84 | .idea/**/gradle.xml 85 | .idea/**/libraries 86 | 87 | # Mongo Explorer plugin 88 | .idea/**/mongoSettings.xml 89 | 90 | # File-based project format 91 | *.iws 92 | 93 | # IntelliJ 94 | out/ 95 | 96 | # mpeltonen/sbt-idea plugin 97 | .idea_modules/ 98 | 99 | # JIRA plugin 100 | atlassian-ide-plugin.xml 101 | 102 | # Cursive Clojure plugin 103 | .idea/replstate.xml 104 | 105 | # Crashlytics plugin (for Android Studio and IntelliJ) 106 | com_crashlytics_export_strings.xml 107 | crashlytics.properties 108 | crashlytics-build.properties 109 | fabric.properties 110 | 111 | # Editor-based Rest Client 112 | .idea/httpRequests 113 | 114 | # Sonarlint plugin 115 | .idea/sonarlint 116 | 117 | 118 | ### SublimeText ### 119 | 120 | # Cache files for Sublime Text 121 | *.tmlanguage.cache 122 | *.tmPreferences.cache 123 | *.stTheme.cache 124 | 125 | # Workspace files are user-specific 126 | *.sublime-workspace 127 | 128 | # Project files should be checked into the repository, unless a significant 129 | # proportion of contributors will probably not be using Sublime Text 130 | # *.sublime-project 131 | 132 | # SFTP configuration file 133 | sftp-config.json 134 | 135 | # Package control specific files 136 | Package Control.last-run 137 | Package Control.ca-list 138 | Package Control.ca-bundle 139 | Package Control.system-ca-bundle 140 | Package Control.cache/ 141 | Package Control.ca-certs/ 142 | Package Control.merged-ca-bundle 143 | Package Control.user-ca-bundle 144 | oscrypto-ca-bundle.crt 145 | bh_unicode_properties.cache 146 | 147 | # Sublime-github package stores a github token in this file 148 | # https://packagecontrol.io/packages/sublime-github 149 | GitHub.sublime-settings 150 | 151 | 152 | ### TextMate ### 153 | 154 | *.tmproj 155 | *.tmproject 156 | tmtags 157 | 158 | 159 | ### Vim ### 160 | 161 | # Swap 162 | [._]*.s[a-v][a-z] 163 | [._]*.sw[a-p] 164 | [._]s[a-rt-v][a-z] 165 | [._]ss[a-gi-z] 166 | [._]sw[a-p] 167 | 168 | # Session 169 | Session.vim 170 | 171 | # Temporary 172 | .netrwhist 173 | # Auto-generated tag files 174 | tags 175 | # Persistent undo 176 | [._]*.un~ 177 | 178 | 179 | ### VisualStudioCode ### 180 | .vscode/ 181 | 182 | ### VisualStudioCode Patch ### 183 | # Ignore all local history of files 184 | .history 185 | 186 | 187 | 188 | ####################################### 189 | ### Django/Python Stack 190 | ####################################### 191 | 192 | 193 | ### Django ### 194 | 195 | *.log 196 | *.pot 197 | *.pyc 198 | __pycache__/ 199 | local_settings.py 200 | db.sqlite3 201 | 202 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 203 | # in your Git repository. Update and uncomment the following line accordingly. 204 | # /staticfiles/ 205 | 206 | 207 | ### Django.Python Stack ### 208 | 209 | # Byte-compiled / optimized / DLL files 210 | *.py[cod] 211 | *$py.class 212 | 213 | # C extensions 214 | *.so 215 | 216 | # Distribution / packaging 217 | .Python 218 | build/ 219 | develop-eggs/ 220 | dist/ 221 | downloads/ 222 | eggs/ 223 | .eggs/ 224 | lib/ 225 | lib64/ 226 | parts/ 227 | sdist/ 228 | var/ 229 | wheels/ 230 | share/python-wheels/ 231 | *.egg-info/ 232 | .installed.cfg 233 | *.egg 234 | MANIFEST 235 | 236 | # PyInstaller 237 | # Usually these files are written by a python script from a template 238 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 239 | *.manifest 240 | *.spec 241 | 242 | # Installer logs 243 | pip-log.txt 244 | pip-delete-this-directory.txt 245 | 246 | # Unit test / coverage reports 247 | htmlcov/ 248 | .tox/ 249 | .nox/ 250 | .coverage 251 | .coverage.* 252 | .cache 253 | nosetests.xml 254 | coverage.xml 255 | *.cover 256 | .hypothesis/ 257 | .pytest_cache/ 258 | junit/ 259 | 260 | # Translations 261 | *.mo 262 | 263 | # Flask stuff: 264 | instance/ 265 | .webassets-cache 266 | 267 | # Scrapy stuff: 268 | .scrapy 269 | 270 | # Sphinx documentation 271 | docs/_build/ 272 | 273 | # PyBuilder 274 | target/ 275 | 276 | # Jupyter Notebook 277 | .ipynb_checkpoints 278 | 279 | # IPython 280 | profile_default/ 281 | ipython_config.py 282 | 283 | # pyenv 284 | .python-version 285 | 286 | # celery beat schedule file 287 | celerybeat-schedule 288 | 289 | # SageMath parsed files 290 | *.sage.py 291 | 292 | # Environments 293 | .env 294 | .venv 295 | env/ 296 | venv/ 297 | ENV/ 298 | env.bak/ 299 | venv.bak/ 300 | 301 | # Spyder project settings 302 | .spyderproject 303 | .spyproject 304 | 305 | # Rope project settings 306 | .ropeproject 307 | 308 | # mkdocs documentation 309 | /site 310 | 311 | # mypy 312 | .mypy_cache/ 313 | .dmypy.json 314 | dmypy.json 315 | 316 | # Pyre type checker 317 | .pyre/ 318 | 319 | 320 | ### OSX ### 321 | 322 | # General 323 | .DS_Store 324 | .AppleDouble 325 | .LSOverride 326 | 327 | # Icon must end with two \r 328 | Icon 329 | 330 | # Thumbnails 331 | ._* 332 | 333 | # Files that might appear in the root of a volume 334 | .DocumentRevisions-V100 335 | .fseventsd 336 | .Spotlight-V100 337 | .TemporaryItems 338 | .Trashes 339 | .VolumeIcon.icns 340 | .com.apple.timemachine.donotpresent 341 | 342 | # Directories potentially created on remote AFP share 343 | .AppleDB 344 | .AppleDesktop 345 | Network Trash Folder 346 | Temporary Items 347 | .apdisk 348 | 349 | 350 | 351 | ####################################### 352 | ### Operating Systems 353 | ####################################### 354 | 355 | 356 | ### Windows ### 357 | 358 | # Windows thumbnail cache files 359 | Thumbs.db 360 | ehthumbs.db 361 | ehthumbs_vista.db 362 | 363 | # Dump file 364 | *.stackdump 365 | 366 | # Folder config file 367 | [Dd]esktop.ini 368 | 369 | # Recycle Bin used on file shares 370 | $RECYCLE.BIN/ 371 | 372 | # Windows Installer files 373 | *.cab 374 | *.msi 375 | *.msix 376 | *.msm 377 | *.msp 378 | 379 | # Windows shortcuts 380 | *.lnk 381 | 382 | 383 | ####################################### 384 | ### Custom 385 | ####################################### 386 | 387 | testproject/**/*.css 388 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2019, CodeRed LLC 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, this 13 | list of conditions and the following disclaimer in the documentation and/or 14 | other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from this 18 | software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 23 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 24 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 25 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 27 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 28 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 29 | OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.rst *.txt *.md 2 | graft django_sass 3 | global-exclude __pycache__ 4 | global-exclude *.py[co] 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-sass 2 | =========== 3 | 4 | The absolute simplest way to use [Sass](https://sass-lang.com/) with Django. 5 | Pure Python, minimal dependencies, and no special configuration required. 6 | 7 | [Source code on GitHub](https://github.com/coderedcorp/django-sass) 8 | 9 | 10 | Status 11 | ------ 12 | 13 | | | | 14 | |------------------------|----------------------| 15 | | Python Package | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-sass)](https://pypi.org/project/django-sass/) [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-sass)](https://pypi.org/project/django-sass/) [![PyPI - Wheel](https://img.shields.io/pypi/wheel/django-sass)](https://pypi.org/project/django-sass/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/django-sass)](https://pypi.org/project/django-sass/) [![PyPI](https://img.shields.io/pypi/v/django-sass)](https://pypi.org/project/django-sass/) | 16 | | Build | [![Build Status](https://dev.azure.com/coderedcorp/cr-github/_apis/build/status/django-sass?branchName=main)](https://dev.azure.com/coderedcorp/cr-github/_build/latest?definitionId=10&branchName=main) [![Azure DevOps tests (branch)](https://img.shields.io/azure-devops/tests/coderedcorp/cr-github/10/main)](https://dev.azure.com/coderedcorp/cr-github/_build/latest?definitionId=10&branchName=main) [![Azure DevOps coverage (branch)](https://img.shields.io/azure-devops/coverage/coderedcorp/cr-github/10/main)](https://dev.azure.com/coderedcorp/cr-github/_build/latest?definitionId=10&branchName=main) | 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | 1. Install from pip. 23 | 24 | ``` 25 | pip install django-sass 26 | ``` 27 | 28 | 2. Add to your `INSTALLED_APPS` (you only need to do this in a dev environment, 29 | you would not want this in your production settings file, although it adds zero 30 | overhead): 31 | 32 | ```python 33 | INSTALLED_APPS = [ 34 | ..., 35 | 'django_sass', 36 | ] 37 | ``` 38 | 39 | 3. Congratulations, you're done 😀 40 | 41 | 42 | Usage 43 | ----- 44 | 45 | In your app's static files, use Sass as normal. The only difference is that 46 | you can **not** traverse upwards using `../` in `@import` statements. For example: 47 | 48 | ``` 49 | app1/ 50 | |- static/ 51 | |- app1/ 52 | |- scss/ 53 | |- _colors.scss 54 | |- app1.scss 55 | app2/ 56 | |- static/ 57 | |- app2/ 58 | |- scss/ 59 | |- _colors.scss 60 | |- app2.scss 61 | ``` 62 | 63 | In `app2.scss` you could reference app1's and app2's `_colors.scss` import as so: 64 | 65 | ```scss 66 | @import 'app1/scss/colors'; 67 | @import 'app2/scss/colors'; 68 | // Or since you are in app2, you can reference its colors with a relative path. 69 | @import 'colors'; 70 | ``` 71 | 72 | Then to compile `app2.scss` and put it in the `css` directory, 73 | run the following management command (the `-g` will build a source map, which 74 | is helpful for debugging CSS): 75 | 76 | ``` 77 | python manage.py sass app2/static/app2/scss/app2.scss app2/static/app2/css/app2.css -g 78 | ``` 79 | 80 | Or, you can compile the entire `scss` directory into 81 | a corresponding `css` directory. This will traverse all subdirectories as well: 82 | 83 | ``` 84 | python manage.py sass app2/static/app2/scss/ app2/static/app2/css/ 85 | ``` 86 | 87 | In your Django HTML template, reference the CSS file as normal: 88 | 89 | ```html 90 | {% load static %} 91 | 92 | ``` 93 | 94 | ✨✨ **Congratulations, you are now a Django + Sass developer!** ✨✨ 95 | 96 | Now you can commit those CSS files to version control, or run `collectstatic` 97 | and deploy them as normal. 98 | 99 | For an example project layout, see `testproject/` in this repository. 100 | 101 | 102 | Watch Mode 103 | ---------- 104 | 105 | To have `django-sass` watch files and recompile them as they change (useful in 106 | development), add the ``--watch`` flag. 107 | 108 | ``` 109 | python manage.py sass app2/static/app2/scss/ app2/static/app2/css/ --watch 110 | ``` 111 | 112 | 113 | Example: deploying compressed CSS to production 114 | ----------------------------------------------- 115 | 116 | To compile minified CSS, use the `-t` flag to specify compression level (one of: 117 | "expanded", "nested", "compact", "compressed"). The default is "expanded" which 118 | is human-readable. 119 | 120 | ``` 121 | python manage.py sass app2/static/app2/scss/ app2/static/app2/css/ -t compressed 122 | ``` 123 | 124 | You may now optionally commit the CSS files to version control if so desired, 125 | or omit them, whatever fits your needs better. Then run `collectsatic` as normal. 126 | 127 | ``` 128 | python manage.py collectstatic 129 | ``` 130 | 131 | And now proceed with deploying your files as normal. 132 | 133 | 134 | Limitations 135 | ----------- 136 | 137 | * `@import` statements must reference a path relative to a path in 138 | `STATICFILES_FINDERS` (which will usually be an app's `static/` directory or 139 | some other directory specified in `STATICFILES_DIRS`). Or they can reference a 140 | relative path equal to or below the current file. It does not support 141 | traversing up the filesystem (i.e. `../`). 142 | 143 | Legal imports: 144 | ```scss 145 | @import 'file-from-currdir'; 146 | @import 'subdir/file'; 147 | @import 'another-app/file'; 148 | ``` 149 | Illegal imports: 150 | ```scss 151 | @import '../file'; 152 | ``` 153 | 154 | * Only supports `-g`, `-p`, and `-t` options similar to `pysassc`. Ideally 155 | `django-sass` will be as similar as possible to the `pysassc` command line 156 | interface. 157 | 158 | Feel free to file an issue or make a pull request to improve any of these 159 | limitations. 🐱‍💻 160 | 161 | 162 | Why django-sass? 163 | ---------------- 164 | 165 | Other packages such as 166 | [django-libsass](https://github.com/torchbox/django-libsass) and 167 | [django-sass-processor](https://github.com/jrief/django-sass-processor), while 168 | nice packages, require `django-compressor` which itself depends on several other 169 | packages that require compilation to install. 170 | 171 | Installing `django-compressor` in your production web server requires a LOT of 172 | extra bloat including a C compiler. It then will compile the Sass on-the-fly 173 | while rendering the HTML templates. This is a wasteful use of CPU on your web 174 | server. 175 | 176 | Instead, `django-sass` lets you compile the Sass locally on your machine 177 | *before* deploying, to reduce dependencies and CPU time on your production web 178 | server. This helps keep things fast and simple. 179 | 180 | * If you simply want to use Sass in development without installing a web of 181 | unwanted dependencies, then `django-sass` is for you. 182 | * If you don't want to deploy any processors or compressors to your production 183 | server, then `django-sass` is for you. 184 | * If you don't want to change the way you reference and serve static files, 185 | then `django-sass` is for you. 186 | * And if you want the absolute simplest installation and setup possible for 187 | doing Sass, `django-sass` is for you too. 188 | 189 | django-sass only depends on libsass (which provides pre-built wheels for 190 | Windows, Mac, and Linux), and of course Django (any version). 191 | 192 | 193 | Programmatically Compiling Sass 194 | ------------------------------- 195 | 196 | You can also use `django-sass` in Python to programmatically compile the sass. 197 | This is useful for build scripts and static site generators. 198 | 199 | ```python 200 | from django_sass import compile_sass 201 | 202 | # Compile scss and write to output file. 203 | compile_sass( 204 | inpath="/path/to/file.scss", 205 | outpath="/path/to/output.css", 206 | output_style="compressed", 207 | precision=8, 208 | source_map=True 209 | ) 210 | ``` 211 | 212 | For more advanced usage, you can specify additional sass search paths outside 213 | of your Django project by using the `include_paths` argument. 214 | 215 | ```python 216 | from django_sass import compile_sass, find_static_paths 217 | 218 | # Get Django's static paths. 219 | dirs = find_static_paths() 220 | 221 | # Add external paths. 222 | dirs.append("/external/path/") 223 | 224 | # Compile scss and write to output file. 225 | compile_sass( 226 | inpath="/path/to/file.scss", 227 | outpath="/path/to/output.css", 228 | output_style="compressed", 229 | precision=8, 230 | source_map=True, 231 | include_paths=dirs, 232 | ) 233 | ``` 234 | 235 | Contributing 236 | ------------ 237 | 238 | To set up a development environment, first check out this repository, create a 239 | venv, then: 240 | 241 | ``` 242 | (myvenv)$ pip install -r requirements-dev.txt 243 | ``` 244 | 245 | Before committing, run static analysis tools: 246 | 247 | ``` 248 | (myvenv)$ black . 249 | (myvenv)$ flake8 250 | (myvenv)$ mypy 251 | ``` 252 | 253 | Then run the unit tests: 254 | 255 | ``` 256 | (myvenv)$ pytest 257 | ``` 258 | 259 | 260 | Changelog 261 | --------- 262 | 263 | #### 1.1.0 264 | * New: Now compiles `.sass` files as well as `.scss` files. 265 | * Fix bug when input path is a file and output path does not exist. 266 | 267 | #### 1.0.1 268 | * Maintanence release, no functional changes. 269 | * Add additional type hints within the codebase. 270 | * Tested against Django 3.1 271 | * Formatted code with `black`. 272 | 273 | #### 1.0.0 274 | * New: You can now use `django_sass` APIs directly in Python. 275 | * Added unit tests. 276 | * Code quality improvements. 277 | 278 | #### 0.2.0 279 | * New feature: `-g` option to build a source map (when input is a file, not a 280 | directory). 281 | 282 | #### 0.1.2 283 | * Fix: Write compiled CSS files as UTF-8. 284 | * Change: Default `-p` precision from 5 to 8 for better support building 285 | Bootstrap CSS. 286 | 287 | #### 0.1.1 288 | * Fix: Create full file path if not exists when specifying a file output. 289 | 290 | #### 0.1.0 291 | * Initial release 292 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Add steps that analyze code, save build artifacts, deploy, and more: 2 | # https://docs.microsoft.com/azure/devops/pipelines/languages/python 3 | # 4 | # NOTES: 5 | # 6 | # Display name of each step should be prefixed with one of the following: 7 | # CR-QC: for quality control measures. 8 | # CR-BUILD: for build-related tasks. 9 | # CR-DEPLOY: for publication or deployment. 10 | # [no prefix]: for unrelated CI setup/tooling. 11 | # 12 | # Use PowerShell Core for any utility scripts so they are re-usable across 13 | # Windows, macOS, and Linux. 14 | # 15 | 16 | 17 | trigger: 18 | - main 19 | 20 | 21 | stages: 22 | - stage: Unit_Tests 23 | displayName: Unit Tests 24 | 25 | jobs: 26 | - job: pytest 27 | displayName: pytest 28 | pool: 29 | vmImage: 'ubuntu-latest' 30 | strategy: 31 | matrix: 32 | py3.6: 33 | PYTHON_VERSION: '3.6' 34 | py3.7: 35 | PYTHON_VERSION: '3.7' 36 | py3.8: 37 | PYTHON_VERSION: '3.8' 38 | py3.9: 39 | PYTHON_VERSION: '3.9' 40 | 41 | steps: 42 | - task: UsePythonVersion@0 43 | displayName: 'Use Python version' 44 | inputs: 45 | versionSpec: '$(PYTHON_VERSION)' 46 | architecture: 'x64' 47 | 48 | - script: python -m pip install -r requirements-dev.txt 49 | displayName: 'CR-QC: Install from local repo' 50 | 51 | - script: pytest ./testproject/ 52 | displayName: 'CR-QC: Run unit tests' 53 | 54 | - task: PublishTestResults@2 55 | displayName: 'Publish unit test report' 56 | condition: succeededOrFailed() 57 | inputs: 58 | testResultsFiles: '**/test-*.xml' 59 | testRunTitle: 'Publish test results for Python $(python.version)' 60 | 61 | - task: PublishCodeCoverageResults@1 62 | displayName: 'Publish code coverage report' 63 | condition: succeededOrFailed() 64 | inputs: 65 | codeCoverageTool: Cobertura 66 | summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage.xml' 67 | 68 | 69 | - stage: Static_Analysis 70 | displayName: Static Analysis 71 | dependsOn: Unit_Tests 72 | condition: succeeded('Unit_Tests') 73 | jobs: 74 | - job: lint 75 | displayName: Linters 76 | pool: 77 | vmImage: 'ubuntu-latest' 78 | 79 | steps: 80 | - task: UsePythonVersion@0 81 | displayName: 'Use Python version' 82 | inputs: 83 | versionSpec: '3.9' 84 | architecture: 'x64' 85 | 86 | - script: python -m pip install -r requirements-dev.txt 87 | displayName: 'CR-QC: Install from local repo' 88 | 89 | - script: flake8 . 90 | displayName: 'CR-QC: Static analysis (flake8)' 91 | 92 | - script: black --check . 93 | displayName: 'CR-QC: Format check' 94 | 95 | - script: mypy . 96 | displayName: 'CR-QC: Type check (mypy)' 97 | 98 | - job: codecov 99 | displayName: Code Coverage 100 | pool: 101 | vmImage: 'ubuntu-latest' 102 | 103 | steps: 104 | - task: DownloadPipelineArtifact@2 105 | displayName: 'Download code coverage from current build' 106 | inputs: 107 | source: 'current' 108 | path: '$(Agent.WorkFolder)/current-artifacts' 109 | project: '$(System.TeamProjectId)' 110 | pipeline: '$(System.DefinitionId)' 111 | 112 | - pwsh: ./ci/compare-codecov.ps1 -wd $Env:WorkDir 113 | displayName: 'CR-QC: Compare code coverage' 114 | env: 115 | WorkDir: $(Agent.WorkFolder) 116 | -------------------------------------------------------------------------------- /ci/compare-codecov.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | <# 4 | .SYNOPSIS 5 | Compares code coverage percent of local coverage.xml file to main branch 6 | (Azure Pipeline API). 7 | 8 | .PARAMETER wd 9 | The working directory in which to search for current coverage.xml. 10 | 11 | .PARAMETER org 12 | Name of the Azure DevOps organization where the pipeline is hosted. 13 | 14 | .PARAMETER project 15 | Name of the Azure DevOps project to which the pipeline belongs. 16 | 17 | .PARAMETER pipeline_name 18 | Name of the desired pipeline within the project. This is to support projects 19 | with multiple pipelines. 20 | #> 21 | 22 | 23 | # ---- SETUP ------------------------------------------------------------------- 24 | 25 | 26 | param( 27 | [string] $wd = (Get-Item (Split-Path $PSCommandPath -Parent)).Parent, 28 | [string] $org = "coderedcorp", 29 | [string] $project = "cr-github", 30 | [string] $pipeline_name = "django-sass" 31 | ) 32 | 33 | # Hide "UI" and progress bars. 34 | $ProgressPreference = "SilentlyContinue" 35 | 36 | # API setup. 37 | $ApiBase = "https://dev.azure.com/$org/$project" 38 | 39 | 40 | # ---- GET CODE COVERAGE FROM RECENT BUILD ------------------------------------- 41 | 42 | 43 | # Get list of all recent builds. 44 | $mainBuildJson = ( 45 | Invoke-WebRequest "$ApiBase/_apis/build/builds?branchName=refs/heads/main&api-version=5.1" 46 | ).Content | ConvertFrom-Json 47 | 48 | # Get the latest matching build ID from the list of builds. 49 | foreach ($build in $mainBuildJson.value) { 50 | if ($build.definition.name -eq $pipeline_name) { 51 | $mainLatestId = $build.id 52 | break 53 | } 54 | } 55 | 56 | # Retrieve code coverage for this build ID. 57 | $mainCoverageJson = ( 58 | Invoke-WebRequest "$ApiBase/_apis/test/codecoverage?buildId=$mainLatestId&api-version=5.1-preview.1" 59 | ).Content | ConvertFrom-Json 60 | foreach ($cov in $mainCoverageJson.coverageData.coverageStats) { 61 | if ($cov.label -eq "Lines") { 62 | $mainlinerate = [math]::Round(($cov.covered / $cov.total) * 100, 2) 63 | } 64 | } 65 | 66 | 67 | # ---- GET COVERAGE FROM LOCAL RUN --------------------------------------------- 68 | 69 | 70 | # Get current code coverage from coverage.xml file. 71 | $coveragePath = Get-ChildItem -Recurse -Filter "coverage.xml" $wd 72 | if (Test-Path -Path $coveragePath) { 73 | [xml]$BranchXML = Get-Content $coveragePath 74 | } 75 | else { 76 | Write-Host -ForegroundColor Red ` 77 | "No code coverage from this build. Is pytest configured to output code coverage? Exiting." 78 | exit 1 79 | } 80 | $branchlinerate = [math]::Round([decimal]$BranchXML.coverage.'line-rate' * 100, 2) 81 | 82 | 83 | # ---- PRINT OUTPUT ------------------------------------------------------------ 84 | 85 | 86 | Write-Output "" 87 | Write-Output "Main coverage rate: $mainlinerate%" 88 | Write-Output "Branch coverage rate: $branchlinerate%" 89 | 90 | if ($mainlinerate -eq 0) { 91 | $change = "Infinite" 92 | } 93 | else { 94 | $change = [math]::Abs($branchlinerate - $mainlinerate) 95 | } 96 | 97 | if ($branchlinerate -gt $mainlinerate) { 98 | Write-Host "Coverage increased by $change% 😀" -ForegroundColor Green 99 | exit 0 100 | } 101 | elseif ($branchlinerate -eq $mainlinerate) { 102 | Write-Host "Coverage has not changed." -ForegroundColor Green 103 | exit 0 104 | } 105 | else { 106 | Write-Host "Coverage decreased by $change% 😭" -ForegroundColor Red 107 | exit 4 108 | } 109 | -------------------------------------------------------------------------------- /django_sass/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import os 3 | 4 | from django.contrib.staticfiles.finders import get_finders 5 | import sass 6 | 7 | 8 | def find_static_paths() -> List[str]: 9 | """ 10 | Finds all static paths available in this Django project. 11 | 12 | :returns: 13 | List of paths containing static files. 14 | """ 15 | found_paths = [] 16 | for finder in get_finders(): 17 | if hasattr(finder, "storages"): 18 | for appname in finder.storages: 19 | if hasattr(finder.storages[appname], "location"): 20 | abspath = finder.storages[appname].location 21 | found_paths.append(abspath) 22 | return found_paths 23 | 24 | 25 | def find_static_scss() -> List[str]: 26 | """ 27 | Finds all static scss/sass files available in this Django project. 28 | 29 | :returns: 30 | List of paths of static scss/sass files. 31 | """ 32 | scss_files = [] 33 | for finder in get_finders(): 34 | for path, storage in finder.list([]): 35 | if path.endswith(".scss") or path.endswith(".sass"): 36 | fullpath = finder.find(path) 37 | scss_files.append(fullpath) 38 | return scss_files 39 | 40 | 41 | def compile_sass( 42 | inpath: str, 43 | outpath: str, 44 | output_style: str = None, 45 | precision: int = None, 46 | source_map: bool = False, 47 | include_paths: List[str] = None, 48 | ) -> None: 49 | """ 50 | Calls sass.compile() within context of Django's known static file paths, 51 | and writes output CSS and/or sourcemaps to file. 52 | 53 | :param str inpath: 54 | Path to SCSS/Sass file or directory of SCSS/Sass files. 55 | :param str outpath: 56 | Path to a CSS file or directory in which to write output. The path will 57 | be created if it does not exist. 58 | :param str output_style: 59 | Corresponds to `output_style` from sass package. 60 | :param int precision: 61 | Corresponds to `precision` from sass package. 62 | :param bool source_map: 63 | If True, write a source map along with the output CSS file. 64 | Only valid when `inpath` is a file. 65 | :returns: 66 | None 67 | """ 68 | 69 | # If include paths are not specified, use Django static paths 70 | include_paths = include_paths or find_static_paths() 71 | 72 | # Additional sass args that must be figured out. 73 | sassargs = {} # type: Dict[str, object] 74 | 75 | # Handle input directories. 76 | if os.path.isdir(inpath): 77 | # Assume outpath is also a dir, or make it. 78 | if not os.path.exists(outpath): 79 | os.makedirs(outpath) 80 | if os.path.isdir(outpath): 81 | sassargs.update({"dirname": (inpath, outpath)}) 82 | else: 83 | raise NotADirectoryError( 84 | "Output path must also be a directory when input path is a directory." 85 | ) 86 | 87 | # Handle input files. 88 | outfile = None 89 | if os.path.isfile(inpath): 90 | 91 | sassargs.update({"filename": inpath}) 92 | 93 | # If outpath does not exist, guess if it should be a dir and create it. 94 | if not os.path.exists(outpath): 95 | if not outpath.endswith(".css"): 96 | os.makedirs(outpath) 97 | 98 | # If outpath is a directory, create a child file. 99 | # Otherwise use provided file path. 100 | if os.path.exists(outpath) and os.path.isdir(outpath): 101 | outfile = os.path.join( 102 | outpath, 103 | os.path.basename( 104 | inpath.replace(".scss", ".css").replace(".sass", ".css") 105 | ), 106 | ) 107 | else: 108 | outfile = outpath 109 | 110 | # Create source map if specified. 111 | if source_map: 112 | sassargs.update({"source_map_filename": outfile + ".map"}) 113 | 114 | # Compile the sass. 115 | rval = sass.compile( 116 | output_style=output_style, 117 | precision=precision, 118 | include_paths=include_paths, 119 | **sassargs, 120 | ) 121 | 122 | # Write output. 123 | # sass.compile() will return None if used with dirname. 124 | # If used with filename, it will return a string of file contents. 125 | if rval and outfile: 126 | # If we got a css and sourcemap tuple, write the sourcemap. 127 | if isinstance(rval, tuple): 128 | map_outfile = outfile + ".map" 129 | outfile_dir = os.path.dirname(map_outfile) 130 | if not os.path.exists(outfile_dir): 131 | os.makedirs(outfile_dir, exist_ok=True) 132 | file = open(map_outfile, "w", encoding="utf8") 133 | file.write(rval[1]) 134 | file.close() 135 | rval = rval[0] 136 | 137 | # Write the outputted css to file. 138 | outfile_dir = os.path.dirname(outfile) 139 | if not os.path.exists(outfile_dir): 140 | os.makedirs(outfile_dir, exist_ok=True) 141 | file = open(outfile, "w", encoding="utf8") 142 | file.write(rval) 143 | file.close() 144 | -------------------------------------------------------------------------------- /django_sass/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoSassConfig(AppConfig): 5 | name = "django_sass" 6 | -------------------------------------------------------------------------------- /django_sass/management/commands/sass.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import os 3 | import sys 4 | import time 5 | 6 | from django.core.management.base import BaseCommand 7 | import sass 8 | 9 | from django_sass import compile_sass, find_static_scss 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "Runs libsass including all paths from STATICFILES_FINDERS." 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument( 17 | "in", 18 | type=str, 19 | nargs="+", 20 | help="An scss file, or directory containing scss files", 21 | ) 22 | parser.add_argument( 23 | "out", 24 | type=str, 25 | nargs="+", 26 | help="A file or directory in which to output transpiled css", 27 | ) 28 | parser.add_argument( 29 | "-g", 30 | dest="g", 31 | action="store_true", 32 | help="Build a sourcemap. Only applicable if input is a file, not a directory.", 33 | ) 34 | parser.add_argument( 35 | "-t", 36 | type=str, 37 | dest="t", 38 | default="expanded", 39 | help="Output type. One of 'expanded', 'nested', 'compact', 'compressed'", 40 | ) 41 | parser.add_argument( 42 | "-p", 43 | type=int, 44 | dest="p", 45 | default=8, 46 | help="Precision. Defaults to 8", 47 | ) 48 | parser.add_argument( 49 | "--watch", 50 | dest="watch", 51 | action="store_true", 52 | default=False, 53 | help="Watch input path and re-generate css files when scss files are changed.", 54 | ) 55 | 56 | def handle(self, *args, **options) -> None: 57 | """ 58 | Finds all static paths used by the project, and runs sass 59 | including those paths. 60 | """ 61 | 62 | # Parse options. 63 | o_inpath = options["in"][0] 64 | o_outpath = options["out"][0] 65 | o_srcmap = options["g"] 66 | o_precision = options["p"] 67 | o_style = options["t"] 68 | 69 | # Watch files for changes if specified. 70 | if options["watch"]: 71 | try: 72 | self.stdout.write("Watching...") 73 | 74 | # Track list of files to watch and their modified time. 75 | watchfiles = {} # type: Dict[str, float] 76 | while True: 77 | needs_updated = False 78 | 79 | # Build/update list of ALL scss files in static paths. 80 | for fullpath in find_static_scss(): 81 | prev_mtime = watchfiles.get(fullpath, 0) 82 | curr_mtime = os.stat(fullpath).st_mtime 83 | if curr_mtime > prev_mtime: 84 | needs_updated = True 85 | watchfiles.update({fullpath: curr_mtime}) 86 | 87 | # Recompile the sass if needed. 88 | if needs_updated: 89 | # Catch compile errors to keep the watcher running. 90 | try: 91 | compile_sass( 92 | inpath=o_inpath, 93 | outpath=o_outpath, 94 | output_style=o_style, 95 | precision=o_precision, 96 | source_map=o_srcmap, 97 | ) 98 | self.stdout.write( 99 | "Updated files at %s" % time.time() 100 | ) 101 | except sass.CompileError as exc: 102 | self.stdout.write(str(exc)) 103 | 104 | # Go back to sleep. 105 | time.sleep(3) 106 | 107 | except (KeyboardInterrupt, InterruptedError): 108 | self.stdout.write("Bye.") 109 | sys.exit(0) 110 | 111 | # Write css. 112 | self.stdout.write("Writing css...") 113 | compile_sass( 114 | inpath=o_inpath, 115 | outpath=o_outpath, 116 | output_style=o_style, 117 | precision=o_precision, 118 | source_map=o_srcmap, 119 | ) 120 | self.stdout.write("Done.") 121 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 80 3 | target-version = ['py36', 'py37', 'py38'] 4 | # Regular expression of files to exclude. 5 | exclude = ''' 6 | /( 7 | .venv 8 | | venv 9 | | migrations 10 | )/ 11 | ''' 12 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e ./ 2 | black 3 | flake8 4 | mypy 5 | pytest 6 | pytest-cov 7 | pytest-django 8 | sphinx 9 | twine 10 | wheel 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | exclude = .venv,venv,migrations 4 | 5 | [mypy] 6 | ignore_missing_imports = True 7 | namespace_packages = True 8 | 9 | [tool:pytest] 10 | DJANGO_SETTINGS_MODULE = testproject.settings 11 | junit_family = xunit2 12 | addopts = --cov django_sass --cov-report html --cov-report xml --junitxml junit/test-results.xml ./testproject/ 13 | python_files = tests.py test_*.py 14 | filterwarnings = 15 | ignore 16 | default:::django_sass.* 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | with open( 6 | os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf8" 7 | ) as readme: 8 | README = readme.read() 9 | 10 | setup( 11 | name="django-sass", 12 | version="1.1.0", 13 | author="CodeRed LLC", 14 | author_email="info@coderedcorp.com", 15 | url="https://github.com/coderedcorp/django-sass", 16 | description=( 17 | "The absolute simplest way to use Sass with Django. Pure Python, " 18 | "minimal dependencies, and no special configuration required!" 19 | ), 20 | long_description=README, 21 | long_description_content_type="text/markdown", 22 | license="BSD license", 23 | include_package_data=True, 24 | packages=find_packages(), 25 | install_requires=[ 26 | "django", 27 | "libsass", 28 | ], 29 | classifiers=[ 30 | "Environment :: Web Environment", 31 | "Framework :: Django :: 2.0", 32 | "Framework :: Django :: 2.1", 33 | "Framework :: Django :: 2.2", 34 | "Framework :: Django :: 3.0", 35 | "Framework :: Django :: 3.1", 36 | "Framework :: Django", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: BSD License", 39 | "Natural Language :: English", 40 | "Programming Language :: Python :: 3 :: Only", 41 | "Programming Language :: Python :: 3", 42 | "Programming Language :: Python :: 3.6", 43 | "Programming Language :: Python :: 3.7", 44 | "Programming Language :: Python :: 3.8", 45 | "Programming Language :: Python :: 3.9", 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /testproject/app1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderedcorp/django-sass/16fbb498ae4cd9cbcc00a07be3e27d4c9080c161/testproject/app1/__init__.py -------------------------------------------------------------------------------- /testproject/app1/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App1Config(AppConfig): 5 | name = "app1" 6 | -------------------------------------------------------------------------------- /testproject/app1/static/app1/scss/_include.scss: -------------------------------------------------------------------------------- 1 | /* Tests: app1/scss/_include.scss */ 2 | 3 | .app1-include { 4 | color: red; 5 | } 6 | -------------------------------------------------------------------------------- /testproject/app2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderedcorp/django-sass/16fbb498ae4cd9cbcc00a07be3e27d4c9080c161/testproject/app2/__init__.py -------------------------------------------------------------------------------- /testproject/app2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App2Config(AppConfig): 5 | name = "app2" 6 | -------------------------------------------------------------------------------- /testproject/app2/static/app2/scss/_samedir.scss: -------------------------------------------------------------------------------- 1 | /* Tests: app2/scss/_samedir.scss */ 2 | 3 | .app2-samedir { 4 | color: red; 5 | } 6 | -------------------------------------------------------------------------------- /testproject/app2/static/app2/scss/subdir/_subdir.scss: -------------------------------------------------------------------------------- 1 | /* Tests: app2/scss/subdir/_subdir.scss */ 2 | 3 | .app2-subdir { 4 | color: red; 5 | } 6 | -------------------------------------------------------------------------------- /testproject/app2/static/app2/scss/test.scss: -------------------------------------------------------------------------------- 1 | // app1/scss/_include.scss 2 | @import "app1/scss/include"; 3 | 4 | // app2/_samedir.scss 5 | @import "app2/scss/samedir"; 6 | 7 | // app2/subdir/_subdir.scss 8 | @import "app2/scss/subdir/subdir"; 9 | 10 | // _samedir.scss 11 | @import "samedir"; 12 | 13 | // subdir/_subdir.scss 14 | @import "subdir/subdir"; 15 | 16 | // test.scss 17 | /* Tests: app2/scss/test.scss */ 18 | .test { 19 | color: red; 20 | } 21 | -------------------------------------------------------------------------------- /testproject/app3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderedcorp/django-sass/16fbb498ae4cd9cbcc00a07be3e27d4c9080c161/testproject/app3/__init__.py -------------------------------------------------------------------------------- /testproject/app3/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App3Config(AppConfig): 5 | name = "app3" 6 | -------------------------------------------------------------------------------- /testproject/app3/static/app3/sass/indent_test.sass: -------------------------------------------------------------------------------- 1 | /* Tests: app3/sass/indent_test.sass */ 2 | 3 | .test_item 4 | border: 1px solid red 5 | -------------------------------------------------------------------------------- /testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /testproject/requirements.txt: -------------------------------------------------------------------------------- 1 | -e ../ # django-sass locally 2 | django 3 | -------------------------------------------------------------------------------- /testproject/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderedcorp/django-sass/16fbb498ae4cd9cbcc00a07be3e27d4c9080c161/testproject/testproject/__init__.py -------------------------------------------------------------------------------- /testproject/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | from typing import List 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "-_wl=tq26(*wyvfza+ncg_436c53pu81d=07j62+vm5y2pc)f^" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] # type: List[str] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "app1", 36 | "app2", 37 | "app3", 38 | "django_sass", 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | "django.middleware.security.SecurityMiddleware", 49 | "django.contrib.sessions.middleware.SessionMiddleware", 50 | "django.middleware.common.CommonMiddleware", 51 | "django.middleware.csrf.CsrfViewMiddleware", 52 | "django.contrib.auth.middleware.AuthenticationMiddleware", 53 | "django.contrib.messages.middleware.MessageMiddleware", 54 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 55 | ] 56 | 57 | ROOT_URLCONF = "testproject.urls" 58 | 59 | TEMPLATES = [ 60 | { 61 | "BACKEND": "django.template.backends.django.DjangoTemplates", 62 | "DIRS": [], 63 | "APP_DIRS": True, 64 | "OPTIONS": { 65 | "context_processors": [ 66 | "django.template.context_processors.debug", 67 | "django.template.context_processors.request", 68 | "django.contrib.auth.context_processors.auth", 69 | "django.contrib.messages.context_processors.messages", 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = "testproject.wsgi.application" 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 80 | 81 | DATABASES = { 82 | "default": { 83 | "ENGINE": "django.db.backends.sqlite3", 84 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 85 | } 86 | } 87 | 88 | 89 | # Internationalization 90 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 91 | 92 | LANGUAGE_CODE = "en-us" 93 | 94 | TIME_ZONE = "UTC" 95 | 96 | USE_I18N = True 97 | 98 | USE_L10N = True 99 | 100 | USE_TZ = True 101 | 102 | 103 | # Static files (CSS, JavaScript, Images) 104 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 105 | 106 | STATIC_URL = "/static/" 107 | -------------------------------------------------------------------------------- /testproject/testproject/urls.py: -------------------------------------------------------------------------------- 1 | """testproject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /testproject/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /testproject/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | import time 5 | import unittest 6 | from typing import List 7 | 8 | from django_sass import find_static_paths, find_static_scss 9 | 10 | 11 | THIS_DIR = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | SCSS_CONTAINS = [ 14 | "/* Tests: app1/scss/_include.scss */", 15 | "/* Tests: app2/scss/_samedir.scss */", 16 | "/* Tests: app2/scss/subdir/_subdir.scss */", 17 | "/* Tests: app2/scss/test.scss */", 18 | ] 19 | 20 | 21 | class TestDjangoSass(unittest.TestCase): 22 | def setUp(self): 23 | self.outdir = os.path.join(THIS_DIR, "out") 24 | 25 | def tearDown(self): 26 | # Clean up output files 27 | shutil.rmtree(self.outdir, ignore_errors=True) 28 | 29 | def assert_output( 30 | self, 31 | inpath: str, 32 | outpath: str, 33 | real_outpath: str, 34 | contains: List[str], 35 | args: List[str] = None, 36 | ): 37 | # Command to run 38 | args = args or [] 39 | cmd = ["python", "manage.py", "sass", *args, inpath, outpath] 40 | # Run the management command on testproject. 41 | proc = subprocess.run(cmd, cwd=THIS_DIR) 42 | # Verify the process exited cleanly. 43 | self.assertEqual(proc.returncode, 0) 44 | # Verify that the output file exists. 45 | # self.assertTrue(os.path.isfile(real_outpath)) 46 | 47 | # Verify that the file contains expected output from all sass files. 48 | with open(real_outpath, encoding="utf8") as f: 49 | contents = f.read() 50 | for compiled_data in contains: 51 | self.assertTrue(compiled_data in contents) 52 | 53 | def test_find_static_paths(self): 54 | paths = find_static_paths() 55 | # Assert that it found both of our apps' static dirs. 56 | self.assertTrue(os.path.join(THIS_DIR, "app1", "static") in paths) 57 | self.assertTrue(os.path.join(THIS_DIR, "app2", "static") in paths) 58 | 59 | def test_find_static_sass(self): 60 | files = find_static_scss() 61 | # Assert that it found all of our scss files. 62 | self.assertTrue( 63 | os.path.join( 64 | THIS_DIR, "app1", "static", "app1", "scss", "_include.scss" 65 | ) 66 | in files 67 | ) 68 | self.assertTrue( 69 | os.path.join( 70 | THIS_DIR, "app2", "static", "app2", "scss", "_samedir.scss" 71 | ) 72 | in files 73 | ) 74 | self.assertTrue( 75 | os.path.join( 76 | THIS_DIR, "app2", "static", "app2", "scss", "test.scss" 77 | ) 78 | in files 79 | ) 80 | self.assertTrue( 81 | os.path.join( 82 | THIS_DIR, 83 | "app2", 84 | "static", 85 | "app2", 86 | "scss", 87 | "subdir", 88 | "_subdir.scss", 89 | ) 90 | in files 91 | ) 92 | self.assertTrue( 93 | os.path.join( 94 | THIS_DIR, "app3", "static", "app3", "sass", "indent_test.sass" 95 | ) 96 | in files 97 | ) 98 | 99 | def test_cli(self): 100 | # Input and output paths relative to django static dirs. 101 | inpath = os.path.join("app2", "static", "app2", "scss", "test.scss") 102 | outpath = os.path.join(self.outdir, "test_file.css") 103 | self.assert_output( 104 | inpath=inpath, 105 | outpath=outpath, 106 | real_outpath=outpath, 107 | contains=SCSS_CONTAINS, 108 | ) 109 | 110 | def test_cli_dir(self): 111 | # Input and output paths relative to django static dirs. 112 | inpath = os.path.join("app2", "static", "app2", "scss") 113 | # Expected output path on filesystem. 114 | real_outpath = os.path.join(self.outdir, "test.css") 115 | self.assert_output( 116 | inpath=inpath, 117 | outpath=self.outdir, 118 | real_outpath=real_outpath, 119 | contains=SCSS_CONTAINS, 120 | ) 121 | 122 | def test_cli_infile_outdir(self): 123 | # Input is a file; output is non-existant path (without .css extension). 124 | inpath = os.path.join("app2", "static", "app2", "scss", "test.scss") 125 | outpath = os.path.join(self.outdir, "does-not-exist") 126 | # Expected output path on filesystem. 127 | real_outpath = os.path.join(outpath, "test.css") 128 | self.assert_output( 129 | inpath=inpath, 130 | outpath=outpath, 131 | real_outpath=real_outpath, 132 | contains=SCSS_CONTAINS, 133 | ) 134 | 135 | def test_sass_compiles(self): 136 | # Input and output paths relative to django static dirs. 137 | inpath = os.path.join("app3", "static", "app3", "sass") 138 | # Expected output path on filesystem. 139 | real_outpath = os.path.join(self.outdir, "indent_test.css") 140 | self.assert_output( 141 | inpath=inpath, 142 | outpath=self.outdir, 143 | real_outpath=real_outpath, 144 | contains=["/* Tests: app3/sass/indent_test.sass */"], 145 | ) 146 | 147 | def test_cli_srcmap(self): 148 | # Input and output paths relative to django static dirs. 149 | inpath = os.path.join("app2", "static", "app2", "scss", "test.scss") 150 | outpath = os.path.join(self.outdir, "test.css") 151 | self.assert_output( 152 | inpath=inpath, 153 | outpath=outpath, 154 | real_outpath=outpath, 155 | contains=SCSS_CONTAINS, 156 | args=["-g"], 157 | ) 158 | self.assertTrue( 159 | os.path.isfile(os.path.join(self.outdir, "test.css.map")) 160 | ) 161 | 162 | @unittest.skip("Test needs fixed...") 163 | def test_cli_watch(self): 164 | # Input and output paths relative to django static dirs. 165 | inpath = os.path.join("app2", "static", "app2", "scss", "test.scss") 166 | outpath = os.path.join(self.outdir, "test.css") 167 | # Command to run 168 | cmd = ["python", "manage.py", "sass", "--watch", inpath, outpath] 169 | # Run the management command on testproject. 170 | proc = subprocess.Popen(cmd, cwd=THIS_DIR) 171 | time.sleep(0.5) 172 | # TODO: This test is not working. Do not know how to intentionally send 173 | # a KeyboardInterrupt to the subprocess without having unittest/pytest 174 | # immediately die when it sees the interrupt. 175 | try: 176 | proc.send_signal(subprocess.signal.CTRL_C_EVENT) 177 | except KeyboardInterrupt: 178 | # We actually want the keyboard interrupt. 179 | pass 180 | returncode = proc.wait() 181 | # Verify the process exited cleanly. 182 | self.assertEqual(returncode, 0) 183 | # Assert output is correct. 184 | self.assert_output(outpath) 185 | --------------------------------------------------------------------------------