├── sasstastic ├── version.py ├── __main__.py ├── common.py ├── __init__.py ├── logs.py ├── main.py ├── cli.py ├── config.py ├── download.py └── compile.py ├── requirements.txt ├── .gitignore ├── .codecov.yml ├── tests ├── test_cli.py └── requirements.txt ├── LICENSE ├── Makefile ├── setup.cfg ├── .github └── workflows │ └── ci.yml ├── setup.py └── README.md /sasstastic/version.py: -------------------------------------------------------------------------------- 1 | __all__ = ('VERSION',) 2 | 3 | VERSION = '0.0.3' 4 | -------------------------------------------------------------------------------- /sasstastic/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | if __name__ == '__main__': 4 | cli() 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r tests/requirements.txt 2 | 3 | libsass==0.20.0 4 | httpx==0.12.1 5 | pydantic==1.5.1 6 | PyYAML==5.3.1 7 | typer==0.1.0 8 | watchgod==0.6 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /env/ 3 | /env-minimal/ 4 | *.py[cod] 5 | *.egg-info/ 6 | htmlcov/ 7 | .coverage 8 | /todo/ 9 | /sandbox/ 10 | /build/ 11 | /dist/ 12 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [95, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from sasstastic.cli import cli 4 | 5 | runner = CliRunner() 6 | 7 | 8 | def test_print_commands(): 9 | result = runner.invoke(cli, ['--help']) 10 | assert result.exit_code == 0 11 | assert 'Fantastic SASS and SCSS compilation' in result.output 12 | -------------------------------------------------------------------------------- /sasstastic/common.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | __all__ = ('SasstasticError', 'is_file_path') 6 | 7 | 8 | class SasstasticError(RuntimeError): 9 | pass 10 | 11 | 12 | def is_file_path(p: Optional[Path]) -> bool: 13 | return p is not None and re.search(r'\.[a-zA-Z0-9]{1,5}$', p.name) 14 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | black==19.10b0 2 | coverage==5.0.3 3 | flake8==3.7.9 4 | flake8-quotes==2.1.1 5 | isort==4.3.21 6 | mypy==0.761 7 | git+https://github.com/PyCQA/pycodestyle@5c60447 8 | git+https://github.com/PyCQA/pyflakes@c688d2b 9 | pytest==5.3.5 10 | pytest-cov==2.8.1 11 | pytest-mock==2.0.0 12 | pytest-sugar==0.9.2 13 | twine==3.1.1 14 | -------------------------------------------------------------------------------- /sasstastic/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .common import SasstasticError 3 | from .compile import compile_sass 4 | from .config import ConfigModel, load_config 5 | from .download import download_sass 6 | from .main import download_and_compile 7 | from .version import VERSION 8 | 9 | __all__ = ( 10 | 'download_sass', 11 | 'compile_sass', 12 | 'SasstasticError', 13 | 'load_config', 14 | 'ConfigModel', 15 | 'download_and_compile', 16 | 'VERSION', 17 | ) 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Samuel Colvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | isort = isort -rc sasstastic tests 3 | black = black -S -l 120 --target-version py37 sasstastic tests 4 | 5 | .PHONY: install 6 | install: 7 | python -m pip install -U setuptools pip 8 | pip install -U -r requirements.txt 9 | pip install -U -e . 10 | 11 | .PHONY: format 12 | format: 13 | $(isort) 14 | $(black) 15 | 16 | .PHONY: lint 17 | lint: 18 | flake8 sasstastic/ tests/ 19 | $(isort) --check-only -df 20 | $(black) --check 21 | 22 | .PHONY: test 23 | test: 24 | pytest --cov=sasstastic 25 | 26 | .PHONY: testcov 27 | testcov: 28 | pytest --cov=sasstastic 29 | @echo "building coverage html" 30 | @coverage html 31 | 32 | .PHONY: check-dist 33 | check-dist: 34 | python setup.py check -ms 35 | python setup.py sdist 36 | twine check dist/* 37 | 38 | .PHONY: all 39 | all: lint testcov 40 | 41 | .PHONY: clean 42 | clean: 43 | rm -rf `find . -name __pycache__` 44 | rm -f `find . -type f -name '*.py[co]' ` 45 | rm -f `find . -type f -name '*~' ` 46 | rm -f `find . -type f -name '.*~' ` 47 | rm -rf .cache 48 | rm -rf .pytest_cache 49 | rm -rf htmlcov 50 | rm -rf *.egg-info 51 | rm -f .coverage 52 | rm -f .coverage.* 53 | rm -rf build 54 | rm -rf dist 55 | python setup.py clean 56 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | timeout = 10 4 | filterwarnings = error 5 | 6 | [flake8] 7 | max-line-length = 120 8 | max-complexity = 14 9 | inline-quotes = ' 10 | multiline-quotes = """ 11 | ignore = E203, W503 12 | 13 | [coverage:run] 14 | source = sasstastic 15 | branch = True 16 | 17 | [coverage:report] 18 | precision = 2 19 | exclude_lines = 20 | pragma: no cover 21 | raise NotImplementedError 22 | raise NotImplemented 23 | if TYPE_CHECKING: 24 | @overload 25 | 26 | [isort] 27 | line_length=120 28 | known_first_party=sasstastic 29 | multi_line_output=3 30 | include_trailing_comma=True 31 | force_grid_wrap=0 32 | combine_as_imports=True 33 | 34 | [mypy] 35 | follow_imports = silent 36 | strict_optional = True 37 | warn_redundant_casts = True 38 | warn_unused_ignores = True 39 | disallow_any_generics = True 40 | check_untyped_defs = True 41 | no_implicit_reexport = True 42 | warn_unused_configs = True 43 | disallow_subclassing_any = True 44 | disallow_incomplete_defs = True 45 | disallow_untyped_decorators = True 46 | disallow_untyped_calls = True 47 | disallow_untyped_defs = True 48 | 49 | # remaining arguments from `mypy --strict` which cause errors 50 | ;no_implicit_optional = True 51 | ;warn_return_any = True 52 | -------------------------------------------------------------------------------- /sasstastic/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | 4 | import click 5 | 6 | 7 | class ClickHandler(logging.Handler): 8 | formats = { 9 | logging.DEBUG: {'fg': 'white', 'dim': True}, 10 | logging.INFO: {'fg': 'green'}, 11 | logging.WARN: {'fg': 'yellow'}, 12 | } 13 | 14 | def emit(self, record): 15 | log_entry = self.format(record) 16 | if record.levelno == logging.INFO and log_entry.startswith('>>'): 17 | click.secho(log_entry[2:], fg='cyan') 18 | else: 19 | fmt = self.formats.get(record.levelno, {'fg': 'red'}) 20 | click.secho(log_entry, **fmt) 21 | 22 | 23 | def log_config(log_level: str) -> dict: 24 | """ 25 | Setup default config. for dictConfig. 26 | :param log_level: str name or django debugging int 27 | :return: dict suitable for ``logging.config.dictConfig`` 28 | """ 29 | assert log_level in {'DEBUG', 'INFO', 'WARNING', 'ERROR'}, f'wrong log level {log_level}' 30 | return { 31 | 'version': 1, 32 | 'disable_existing_loggers': True, 33 | 'formatters': {'default': {'format': '%(message)s'}, 'indent': {'format': ' %(message)s'}}, 34 | 'handlers': { 35 | 'sasstastic': {'level': log_level, 'class': 'sasstastic.logs.ClickHandler', 'formatter': 'default'}, 36 | }, 37 | 'loggers': {'sasstastic': {'handlers': ['sasstastic'], 'level': log_level, 'propagate': False}}, 38 | } 39 | 40 | 41 | def setup_logging(log_level): 42 | config = log_config(log_level) 43 | logging.config.dictConfig(config) 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | test: 13 | name: test py${{ matrix.python-version }} on ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu, macos, windows] 18 | python-version: ['3.7', '3.8'] 19 | 20 | env: 21 | PYTHON: ${{ matrix.python-version }} 22 | OS: ${{ matrix.os }} 23 | 24 | runs-on: ${{ matrix.os }}-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: set up python 30 | uses: actions/setup-python@v1 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: install dependencies 35 | run: | 36 | make install 37 | pip freeze 38 | 39 | - name: lint 40 | run: | 41 | make lint 42 | make check-dist 43 | 44 | - name: test 45 | run: | 46 | make test 47 | coverage xml 48 | 49 | - uses: codecov/codecov-action@v1.0.7 50 | with: 51 | file: ./coverage.xml 52 | env_vars: OS,PYTHON 53 | 54 | deploy: 55 | name: Deploy 56 | needs: test 57 | if: "success() && startsWith(github.ref, 'refs/tags/')" 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - uses: actions/checkout@v2 62 | 63 | - name: set up python 64 | uses: actions/setup-python@v1 65 | with: 66 | python-version: '3.8' 67 | 68 | - name: install 69 | run: | 70 | make install 71 | pip install -U wheel 72 | 73 | - name: build 74 | run: python setup.py sdist bdist_wheel 75 | 76 | - name: check dist 77 | run: twine check dist/* 78 | 79 | - name: check tag 80 | run: PACKAGE=sasstastic python <(curl -Ls https://git.io/JvQsH) 81 | 82 | - name: upload to pypi 83 | run: twine upload dist/* 84 | env: 85 | TWINE_USERNAME: __token__ 86 | TWINE_PASSWORD: ${{ secrets.pypi_token }} 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from importlib.machinery import SourceFileLoader 2 | from pathlib import Path 3 | 4 | from setuptools import setup 5 | 6 | description = 'Fantastic SASS and SCSS compilation for python' 7 | THIS_DIR = Path(__file__).resolve().parent 8 | try: 9 | long_description = THIS_DIR.joinpath('README.md').read_text() 10 | except FileNotFoundError: 11 | long_description = description 12 | 13 | # avoid loading the package before requirements are installed: 14 | version = SourceFileLoader('version', 'sasstastic/version.py').load_module() 15 | 16 | setup( 17 | name='sasstastic', 18 | version=str(version.VERSION), 19 | description=description, 20 | long_description=long_description, 21 | long_description_content_type='text/markdown', 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3 :: Only', 27 | 'Programming Language :: Python :: 3.7', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Intended Audience :: Developers', 30 | 'Intended Audience :: Information Technology', 31 | 'Intended Audience :: System Administrators', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Operating System :: Unix', 34 | 'Operating System :: POSIX :: Linux', 35 | 'Environment :: MacOS X', 36 | 'Topic :: Internet', 37 | ], 38 | author='Samuel Colvin', 39 | author_email='s@muelcolvin.com', 40 | url='https://github.com/samuelcolvin/sasstastic', 41 | license='MIT', 42 | packages=['sasstastic'], 43 | package_data={'sasstastic': ['py.typed']}, 44 | entry_points=""" 45 | [console_scripts] 46 | sasstastic=sasstastic.__main__:cli 47 | """, 48 | python_requires='>=3.7', 49 | zip_safe=True, 50 | install_requires=[ 51 | 'libsass>=0.20.0', 52 | 'httpx>=0.12.1', 53 | 'pydantic>=1.5', 54 | 'PyYAML>=5.3.1', 55 | 'typer>=0.1.0', 56 | 'watchgod>=0.6', 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /sasstastic/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import watchgod 7 | 8 | from .compile import compile_sass 9 | from .config import ConfigModel, load_config 10 | from .download import Downloader, download_sass 11 | 12 | logger = logging.getLogger('sasstastic.main') 13 | __all__ = 'download_and_compile', 'watch', 'awatch' 14 | 15 | 16 | def download_and_compile(config: ConfigModel, alt_output_dir: Optional[Path] = None, dev_mode: Optional[bool] = None): 17 | logger.info('build path: %s/', config.build_dir) 18 | logger.info('output path: %s/', alt_output_dir or config.output_dir) 19 | 20 | download_sass(config) 21 | compile_sass(config, alt_output_dir, dev_mode) 22 | 23 | 24 | def watch(config: ConfigModel, alt_output_dir: Optional[Path] = None, dev_mode: Optional[bool] = None): 25 | try: 26 | asyncio.run(awatch(config, alt_output_dir, dev_mode)) 27 | except KeyboardInterrupt: 28 | pass 29 | 30 | 31 | async def awatch(config: ConfigModel, alt_output_dir: Optional[Path] = None, dev_mode: Optional[bool] = None): 32 | logger.info('build path: %s/', config.build_dir) 33 | logger.info('output path: %s/', alt_output_dir or config.output_dir) 34 | 35 | await Downloader(config).download() 36 | compile_sass(config, alt_output_dir, dev_mode) 37 | 38 | config_file = str(config.config_file) 39 | async for changes in watch_multiple(config_file, config.build_dir): 40 | changed_paths = {c[1] for c in changes} 41 | if config_file in changed_paths: 42 | logger.info('changes detected in config file, downloading sources...') 43 | config = load_config(config.config_file) 44 | await Downloader(config).download() 45 | 46 | if changed_paths != {config_file}: 47 | logger.info('changes detected in the build directory, re-compiling...') 48 | compile_sass(config, alt_output_dir, dev_mode) 49 | 50 | 51 | async def watch_multiple(*paths): 52 | watchers = [watchgod.awatch(p) for p in paths] 53 | while True: 54 | done, pending = await asyncio.wait([w.__anext__() for w in watchers], return_when=asyncio.FIRST_COMPLETED) 55 | for t in pending: 56 | t.cancel() 57 | for t in done: 58 | yield t.result() 59 | -------------------------------------------------------------------------------- /sasstastic/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import typer 6 | 7 | from .config import SasstasticError, load_config 8 | from .logs import setup_logging 9 | from .main import download_and_compile, watch 10 | from .version import VERSION 11 | 12 | cli = typer.Typer() 13 | logger = logging.getLogger('sasstastic.cli') 14 | 15 | 16 | def version_callback(value: bool): 17 | if value: 18 | print(f'sasstastic: v{VERSION}') 19 | raise typer.Exit() 20 | 21 | 22 | OUTPUT_HELP = 'Custom directory to output css files, if omitted the "output_dir" field from the config file is used.' 23 | DEV_MODE_HELP = 'Whether to compile in development or production mode, if omitted the value is taken from config.' 24 | WATCH_HELP = 'Whether to watch the config file and build directory then download and compile after file changes.' 25 | VERBOSE_HELP = 'Print more information to the console.' 26 | VERSION_HELP = 'Show the version and exit.' 27 | 28 | 29 | @cli.command() 30 | def build( 31 | config_path: Path = typer.Argument('sasstastic.yml', exists=True, file_okay=True, dir_okay=True, readable=True), 32 | output_dir: Optional[Path] = typer.Option( 33 | None, '-o', '--output-dir', file_okay=False, dir_okay=True, readable=True, help=OUTPUT_HELP 34 | ), 35 | dev_mode: bool = typer.Option(None, '--dev/--prod', help=DEV_MODE_HELP), 36 | watch_mode: bool = typer.Option(False, '--watch/--dont-watch', help=WATCH_HELP), 37 | verbose: bool = typer.Option(False, help=VERBOSE_HELP), 38 | version: bool = typer.Option(None, '--version', callback=version_callback, is_eager=True, help=VERSION_HELP), 39 | ): 40 | """ 41 | Fantastic SASS and SCSS compilation. 42 | 43 | Takes a single argument: a path to a sasstastic.yml config file, or a directory containing a sasstastic.yml file. 44 | """ 45 | setup_logging('DEBUG' if verbose else 'INFO') 46 | if config_path.is_dir(): 47 | config_path /= 'sasstastic.yml' 48 | logger.info('config path: %s', config_path) 49 | try: 50 | config = load_config(config_path) 51 | if watch_mode: 52 | watch(config, output_dir, dev_mode) 53 | else: 54 | download_and_compile(config, output_dir, dev_mode) 55 | except SasstasticError: 56 | raise typer.Exit(1) 57 | 58 | 59 | if __name__ == '__main__': 60 | cli() 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sasstastic 2 | 3 | [![CI](https://github.com/samuelcolvin/sasstastic/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/sasstastic/actions?query=event%3Apush+branch%3Amaster+workflow%3ACI) 4 | [![Coverage](https://codecov.io/gh/samuelcolvin/sasstastic/branch/master/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/sasstastic) 5 | [![pypi](https://img.shields.io/pypi/v/sasstastic.svg)](https://pypi.python.org/pypi/sasstastic) 6 | [![versions](https://img.shields.io/pypi/pyversions/sasstastic.svg)](https://github.com/samuelcolvin/sasstastic) 7 | [![license](https://img.shields.io/github/license/samuelcolvin/sasstastic.svg)](https://github.com/samuelcolvin/sasstastic/blob/master/LICENSE) 8 | 9 | **Fantastic SASS and SCSS compilation for python** 10 | 11 | ## Installation 12 | 13 | ```bash 14 | pip install sasstastic 15 | ``` 16 | 17 | run 18 | 19 | ```bash 20 | sasstastic --help 21 | ``` 22 | 23 | To check sasstastic is install and get help info. 24 | 25 | ## Usage 26 | 27 | Define a config file `sasstastic.yml`: 28 | 29 | ```yaml 30 | download: 31 | # downloaded files will be saved in this directory 32 | dir: styles/.libs 33 | sources: 34 | # download a font css file from google fonts and save it to google-fonts.css 35 | - url: > 36 | https://fonts.googleapis.com/css? 37 | family=Merriweather:400,400i,700,700i|Titillium+Web|Ubuntu+Mono&display=swap 38 | to: google-fonts.css 39 | 40 | # download a style sheet from select2, this will be saved to "select2.css" as 41 | # the name can be inferred from the url 42 | - url: 'https://raw.githubusercontent.com/select2/select2/4.0.13/dist/css/select2.css' 43 | 44 | # download the full bootstrap 4 bundle and extract the scss files to the bootstrap/ directory 45 | - url: https://github.com/twbs/bootstrap/archive/v4.4.1.zip 46 | extract: 47 | 'bootstrap-4.4.1/scss/(.+)$': bootstrap/ 48 | 49 | 50 | # SCSS and SASS files will be build from this directory 51 | build_dir: styles/ 52 | # and saved to this directory 53 | output_dir: css/ 54 | ``` 55 | 56 | Then run `sasstastic` to build your sass files. 57 | 58 | note: 59 | * if you `sasstastic.yml` file isn't in the current working directory you can pass the path to that file 60 | as an argument to sasstastic, e.g. `sasstastic path/to/sasstastic.yml` or just `sasstastic path/to/` 61 | * by default the paths defined in `sasstastic.yml`: `download.dir`, `build_dir` and `output_dir` are 62 | **relative to the the `sasstastic.yml` file 63 | * you can override the output directory `ouput_dir` using the `-o` argument to the CLI, see `sasstastic --help` 64 | for more info 65 | * sasstastic can build in "development" or "production" mode: 66 | * in **development** mode css is not compressed, a map file is created and all files from `build_dir` and 67 | `download.dir` are copied into `output_dir` so map files work correctly 68 | * in **production** mode css is compressed, no other files are added to `output_dir` 69 | 70 | ### Watch mode 71 | 72 | You can watch a directory and config file and run sasstastic when files change using `sasstastic --watch`. 73 | -------------------------------------------------------------------------------- /sasstastic/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from pathlib import Path 4 | from typing import Any, Dict, List, Optional, Pattern 5 | 6 | import yaml 7 | from pydantic import BaseModel, HttpUrl, ValidationError, validator 8 | from pydantic.error_wrappers import display_errors 9 | 10 | from .common import SasstasticError, is_file_path 11 | 12 | try: 13 | from yaml import CLoader as Loader 14 | except ImportError: 15 | from yaml import Loader 16 | 17 | __all__ = 'SourceModel', 'DownloadModel', 'ConfigModel', 'load_config' 18 | logger = logging.getLogger('sasstastic.config') 19 | 20 | 21 | class SourceModel(BaseModel): 22 | url: HttpUrl 23 | extract: Optional[Dict[Pattern, Optional[Path]]] = None 24 | to: Optional[Path] = None 25 | 26 | @validator('url', pre=True) 27 | def remove_spaces_from_url(cls, v): 28 | return v and v.replace(' ', '') 29 | 30 | @validator('extract', each_item=True) 31 | def check_extract_path(cls, v): 32 | if v is not None and v.is_absolute(): 33 | raise ValueError('extract path may not be absolute, remove the leading slash') 34 | return v 35 | 36 | @validator('to', always=True) 37 | def check_to(cls, v, values): 38 | if values.get('extract'): 39 | # extracting, to can be None 40 | return v 41 | elif is_file_path(v): 42 | # to is already a valid path 43 | return v 44 | elif v is not None and v.is_absolute(): 45 | raise ValueError('path may not be absolute, remove the leading slash') 46 | 47 | try: 48 | url: HttpUrl = values['url'] 49 | except KeyError: 50 | return v 51 | else: 52 | filename = (url.path or '/').rsplit('/', 1)[1] 53 | if not filename.endswith(('.css', '.sass', '.scss')): 54 | raise ValueError(f'no filename found in url "{url}" and file path not given via "to"') 55 | return (v or Path('.')) / filename 56 | 57 | 58 | class DownloadModel(BaseModel): 59 | dir: Path 60 | sources: List[SourceModel] 61 | 62 | 63 | class ConfigModel(BaseModel): 64 | download: Optional[DownloadModel] = None 65 | build_dir: Path 66 | output_dir: Path 67 | lock_file: Path = Path('.sasstastic.lock') 68 | include_files: Pattern = re.compile(r'^[^_].+\.(?:css|sass|scss)$') 69 | exclude_files: Optional[Pattern] = None 70 | replace: Optional[Dict[Pattern, Dict[Pattern, str]]] = None 71 | file_hashes: bool = False 72 | dev_mode: bool = True 73 | config_file: Path 74 | 75 | @classmethod 76 | def parse_obj(cls, config_file: Path, obj: Dict[str, Any]) -> 'ConfigModel': 77 | if isinstance(obj, dict): 78 | obj['config_file'] = config_file 79 | m: ConfigModel = super().parse_obj(obj) 80 | 81 | config_directory = config_file.parent 82 | if not m.download.dir.is_absolute(): 83 | m.download.dir = config_directory / m.download.dir 84 | 85 | if not m.build_dir.is_absolute(): 86 | m.build_dir = config_directory / m.build_dir 87 | 88 | if not m.output_dir.is_absolute(): 89 | m.output_dir = config_directory / m.output_dir 90 | 91 | if not m.lock_file.is_absolute(): 92 | m.lock_file = config_directory / m.lock_file 93 | return m 94 | 95 | 96 | def load_config(config_file: Path) -> ConfigModel: 97 | if not config_file.is_file(): 98 | logger.error('%s does not exist', config_file) 99 | raise SasstasticError('config files does not exist') 100 | try: 101 | with config_file.open('r') as f: 102 | data = yaml.load(f, Loader=Loader) 103 | except yaml.YAMLError as e: 104 | logger.error('invalid YAML file %s:\n%s', config_file, e) 105 | raise SasstasticError('invalid YAML file') 106 | 107 | try: 108 | return ConfigModel.parse_obj(config_file, data) 109 | except ValidationError as exc: 110 | logger.error('Error parsing %s:\n%s', config_file, display_errors(exc.errors())) 111 | raise SasstasticError('error parsing config file') 112 | -------------------------------------------------------------------------------- /sasstastic/download.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import json 4 | import logging 5 | import re 6 | import zipfile 7 | from io import BytesIO 8 | from itertools import chain 9 | from pathlib import Path 10 | from typing import Dict, Set, Tuple 11 | 12 | from httpx import AsyncClient 13 | 14 | from .common import SasstasticError, is_file_path 15 | from .config import ConfigModel, SourceModel 16 | 17 | __all__ = ('download_sass', 'Downloader') 18 | logger = logging.getLogger('sasstastic.download') 19 | 20 | 21 | def download_sass(config: ConfigModel): 22 | asyncio.run(Downloader(config).download()) 23 | 24 | 25 | class Downloader: 26 | def __init__(self, config: ConfigModel): 27 | self._download_dir = config.download.dir 28 | self._sources = config.download.sources 29 | self._client = AsyncClient() 30 | self._lock_check = LockCheck(self._download_dir, config.lock_file) 31 | 32 | async def download(self): 33 | if not self._sources: 34 | logger.info('\nno files to download') 35 | return 36 | 37 | to_download = [s for s in self._sources if self._lock_check.should_download(s)] 38 | if to_download: 39 | logger.info( 40 | '\ndownloading %d files to %s, %d up-to-date', 41 | len(to_download), 42 | self._download_dir, 43 | len(self._sources) - len(to_download), 44 | ) 45 | try: 46 | await asyncio.gather(*[self._download_source(s) for s in to_download]) 47 | finally: 48 | await self._client.aclose() 49 | self._lock_check.save() 50 | else: 51 | logger.info('\nno new files to download, %d up-to-date', len(self._sources)) 52 | self._lock_check.delete_stale() 53 | 54 | async def _download_source(self, s: SourceModel): 55 | logger.debug('%s: downloading...', s.url) 56 | r = await self._client.get(s.url) 57 | if r.status_code != 200: 58 | logger.error('Error downloading %r, unexpected status code: %s', s.url, r.status_code) 59 | raise SasstasticError(f'unexpected status code {r.status_code}') 60 | 61 | loop = asyncio.get_running_loop() 62 | if s.extract is None: 63 | path = await loop.run_in_executor(None, self._save_file, s.to, r.content) 64 | self._lock_check.record(s, s.to, r.content) 65 | logger.info('>> downloaded %s ➤ %s', s.url, path) 66 | else: 67 | count = await loop.run_in_executor(None, self._extract_zip, s, r.content) 68 | logger.info('>> downloaded %s ➤ extract %d files', s.url, count) 69 | 70 | def _extract_zip(self, s: SourceModel, content: bytes): 71 | zcopied = 0 72 | with zipfile.ZipFile(BytesIO(content)) as zipf: 73 | logger.debug('%s: %d files in zip archive', s.url, len(zipf.namelist())) 74 | 75 | for filepath in zipf.namelist(): 76 | if filepath.endswith('/'): 77 | continue 78 | regex_pattern, match, file_path = None, None, None 79 | for r, t in s.extract.items(): 80 | match = r.match(filepath) 81 | if match: 82 | regex_pattern, file_path = r, t 83 | break 84 | if regex_pattern is None: 85 | logger.debug('%s: "%s" no target found', s.url, filepath) 86 | elif file_path is None: 87 | logger.debug('%s: "%s" skipping (regex: "%s")', s.url, filepath, regex_pattern) 88 | else: 89 | if not is_file_path(file_path): 90 | file_name = match.groupdict().get('filename') or match.groups()[-1] 91 | file_path = file_path / file_name 92 | logger.debug('%s: "%s" ➤ "%s" (regex: "%s")', s.url, filepath, file_path, regex_pattern) 93 | content = zipf.read(filepath) 94 | self._lock_check.record(s, file_path, content) 95 | self._save_file(file_path, content) 96 | zcopied += 1 97 | return zcopied 98 | 99 | def _save_file(self, save_to: Path, content) -> Path: 100 | p = self._download_dir / save_to 101 | p.parent.mkdir(parents=True, exist_ok=True) 102 | p.write_bytes(content) 103 | return p 104 | 105 | 106 | class LockCheck: 107 | """ 108 | Avoid downloading unchanged files by consulting a "lock file" cache. 109 | """ 110 | 111 | file_description = ( 112 | "# this files records information about files downloaded by sasstastic \n" # noqa: Q000 113 | "# to allow unnecessary downloads to be skipped.\n" # noqa: Q000 114 | "# You should't edit it manually and should include it in version control." 115 | ) 116 | 117 | def __init__(self, root_dir: Path, lock_file: Path): 118 | self._root_dir = root_dir 119 | self._lock_file = lock_file 120 | if lock_file.is_file(): 121 | lines = (ln for ln in lock_file.read_text().split('\n') if not re.match(r'\s*#', ln)) 122 | c = json.loads('\n'.join(lines)) 123 | self._cache: Dict[str, Set[Tuple[str, str]]] = {k: {tuple(f) for f in v} for k, v in c.items()} 124 | else: 125 | self._cache = {} 126 | self._active: Set[str] = set() 127 | 128 | def should_download(self, s: SourceModel) -> bool: 129 | k = self._hash_source(s) 130 | files = self._cache.get(k) 131 | if files is None: 132 | return True 133 | else: 134 | self._active.add(k) 135 | return not any(self._file_unchanged(*v) for v in files) 136 | 137 | def record(self, s: SourceModel, path: Path, content: bytes): 138 | k = self._hash_source(s) 139 | r = str(path), hashlib.md5(content).hexdigest() 140 | self._active.add(k) 141 | files = self._cache.get(k) 142 | if files is None: 143 | self._cache[k] = {r} 144 | else: 145 | files.add(r) 146 | 147 | def save(self): 148 | lines = ',\n'.join(f' "{k}": {json.dumps(sorted(v))}' for k, v in self._cache.items() if k in self._active) 149 | self._lock_file.write_text(f'{self.file_description}\n{{\n{lines}\n}}') 150 | 151 | def delete_stale(self): 152 | d_files = set(chain.from_iterable((p for p, _ in f) for u, f in self._cache.items() if u in self._active)) 153 | for p in self._root_dir.glob('**/*'): 154 | rel_path = str(p.relative_to(self._root_dir)) 155 | if rel_path not in d_files and p.is_file(): 156 | p.unlink() 157 | logger.info('>> %s stale and deleted', rel_path) 158 | 159 | def _file_unchanged(self, path: str, file_hash: str) -> bool: 160 | p = self._root_dir / path 161 | return p.is_file() and hashlib.md5(p.read_bytes()).hexdigest() == file_hash 162 | 163 | @staticmethod 164 | def _hash_source(s: SourceModel): 165 | j = str(s.url), None if s.extract is None else {str(k): str(v) for k, v in s.extract.items()}, str(s.to) 166 | return hashlib.md5(json.dumps(j).encode()).hexdigest() 167 | -------------------------------------------------------------------------------- /sasstastic/compile.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import re 5 | import shutil 6 | import tempfile 7 | from contextlib import contextmanager 8 | from pathlib import Path 9 | from time import time 10 | from typing import Optional, Union 11 | 12 | import click 13 | import sass 14 | 15 | from .common import SasstasticError 16 | from .config import ConfigModel 17 | 18 | __all__ = ('compile_sass',) 19 | logger = logging.getLogger('sasstastic.compile') 20 | STARTS_DOWNLOAD = re.compile('^(?:DOWNLOAD|DL)/') 21 | STARTS_SRC = re.compile('^SRC/') 22 | 23 | 24 | def compile_sass(config: ConfigModel, alt_output_dir: Optional[Path] = None, dev_mode: Optional[bool] = None): 25 | if dev_mode is None: 26 | dev_mode = config.dev_mode 27 | else: 28 | dev_mode = dev_mode 29 | mode = 'dev' if dev_mode else 'prod' 30 | out_dir: Path = alt_output_dir or config.output_dir 31 | logger.info('\ncompiling "%s/" to "%s/" (mode: %s)', config.build_dir, out_dir, mode) 32 | with tmpdir() as tmp_path: 33 | SassCompiler(config, tmp_path, dev_mode).build() 34 | fast_move(tmp_path, out_dir) 35 | 36 | 37 | class SassCompiler: 38 | def __init__(self, config: ConfigModel, tmp_out_dir: Path, dev_mode: bool): 39 | self._config = config 40 | self._build_dir = config.build_dir 41 | self._tmp_out_dir = tmp_out_dir 42 | self._dev_mode = dev_mode 43 | self._src_dir = self._build_dir 44 | self._replace = config.replace or {} 45 | self._download_dir = config.download.dir 46 | self._importers = [(5, self._clever_imports)] 47 | 48 | dir_hash = hashlib.md5(str(self._build_dir).encode()).hexdigest() 49 | self._size_cache_file = Path(tempfile.gettempdir()) / 'grablib_cache.{}.json'.format(dir_hash) 50 | 51 | self._output_style = 'nested' if self._dev_mode else 'compressed' 52 | 53 | self._old_size_cache = {} 54 | self._new_size_cache = {} 55 | self._errors = 0 56 | self._files_generated = 0 57 | 58 | def build(self) -> None: 59 | start = time() 60 | 61 | if self._dev_mode: 62 | self._src_dir = out_dir_src = self._tmp_out_dir / '.src' 63 | 64 | shutil.copytree(str(self._build_dir), str(out_dir_src)) 65 | files = sum(f.is_file() for f in out_dir_src.glob('**/*')) 66 | logger.info('>> %28s/* ➤ %-30s %3d files', self._build_dir, '.src/', files) 67 | 68 | try: 69 | self._download_dir = out_dir_src / self._download_dir.relative_to(self._build_dir) 70 | except ValueError: 71 | # download dir is not inside the build dir, need to copy libs too 72 | out_dir_libs = self._tmp_out_dir / '.libs' 73 | shutil.copytree(str(self._download_dir), str(out_dir_libs)) 74 | files = sum(f.is_file() for f in out_dir_libs.glob('**/*')) 75 | logger.info('%28s/* ➤ %-30s %3d files', self._download_dir, '.libs/', files) 76 | self._download_dir = out_dir_src 77 | 78 | if self._size_cache_file.exists(): 79 | with self._size_cache_file.open() as f: 80 | self._old_size_cache = json.load(f) 81 | 82 | for path in self._src_dir.glob('**/*.*'): 83 | self.process_file(path) 84 | 85 | with self._size_cache_file.open('w') as f: 86 | json.dump(self._new_size_cache, f, indent=2) 87 | 88 | time_taken = (time() - start) * 1000 89 | plural = '' if self._files_generated == 1 else 's' 90 | if not self._errors: 91 | logger.info('%d css file%s generated in %0.0fms, 0 errors', self._files_generated, plural, time_taken) 92 | else: 93 | logger.error( 94 | '%d css file%s generated in %0.0fms, %d errors', self._files_generated, plural, time_taken, self._errors 95 | ) 96 | raise SasstasticError('sass errors') 97 | 98 | def process_file(self, f: Path): 99 | if not f.is_file(): 100 | return 101 | if not self._config.include_files.search(f.name): 102 | return 103 | if self._config.exclude_files and self._config.exclude_files.search(str(f)): 104 | return 105 | 106 | if is_relative_to(f, self._download_dir): 107 | return 108 | 109 | rel_path = f.relative_to(self._src_dir) 110 | css_path = (self._tmp_out_dir / rel_path).with_suffix('.css') 111 | 112 | map_path = css_path.with_name(css_path.name + '.map') if self._dev_mode else None 113 | 114 | try: 115 | css = sass.compile( 116 | filename=str(f), 117 | source_map_filename=map_path and str(map_path), 118 | output_style=self._output_style, 119 | precision=10, 120 | importers=self._importers, 121 | ) 122 | except sass.CompileError as e: 123 | self._errors += 1 124 | logger.error('%s compile error:\n%s', f, e) 125 | return 126 | 127 | log_msg = None 128 | file_hashes = self._config.file_hashes 129 | try: 130 | css_path.parent.mkdir(parents=True, exist_ok=True) 131 | if self._dev_mode: 132 | css, css_map = css 133 | 134 | if file_hashes: 135 | css_path = insert_hash(css_path, css) 136 | map_path = insert_hash(map_path, css) 137 | file_hashes = False 138 | 139 | # correct the link to map file in css 140 | css = re.sub(r'/\*# sourceMappingURL=\S+ \*/', f'/*# sourceMappingURL={map_path.name} */', css) 141 | map_path.write_text(css_map) 142 | css, log_msg = self._regex_modify(rel_path, css) 143 | finally: 144 | self._log_file_creation(rel_path, css_path, css) 145 | if log_msg: 146 | logger.debug(log_msg) 147 | 148 | if file_hashes: 149 | css_path = insert_hash(css_path, css) 150 | css_path.write_text(css) 151 | self._files_generated += 1 152 | 153 | def _regex_modify(self, rel_path, css): 154 | log_msg = None 155 | 156 | for path_regex, regex_map in self._replace.items(): 157 | if re.search(path_regex, str(rel_path)): 158 | logger.debug('%s has regex replace matches for "%s"', rel_path, path_regex) 159 | for pattern, repl in regex_map.items(): 160 | hash1 = hash(css) 161 | css = re.sub(pattern, repl, css) 162 | if hash(css) == hash1: 163 | log_msg = ' "{}" ➤ "{}" didn\'t modify the source'.format(pattern, repl) 164 | else: 165 | log_msg = ' "{}" ➤ "{}" modified the source'.format(pattern, repl) 166 | return css, log_msg 167 | 168 | def _log_file_creation(self, rel_path, css_path, css): 169 | src, dst = str(rel_path), str(css_path.relative_to(self._tmp_out_dir)) 170 | 171 | size = len(css.encode()) 172 | p = str(css_path) 173 | self._new_size_cache[p] = size 174 | old_size = self._old_size_cache.get(p) 175 | c = None 176 | if old_size: 177 | change_p = (size - old_size) / old_size * 100 178 | if abs(change_p) > 0.5: 179 | c = 'green' if change_p <= 0 else 'red' 180 | change_p = click.style('{:+0.0f}%'.format(change_p), fg=c) 181 | logger.info('>> %30s ➤ %-30s %9s %s', src, dst, fmt_size(size), change_p) 182 | if c is None: 183 | logger.info('>> %30s ➤ %-30s %9s', src, dst, fmt_size(size)) 184 | 185 | def _clever_imports(self, src_path): 186 | _new_path = None 187 | if STARTS_SRC.match(src_path): 188 | _new_path = self._build_dir / STARTS_SRC.sub('', src_path) 189 | elif STARTS_DOWNLOAD.match(src_path): 190 | _new_path = self._download_dir / STARTS_DOWNLOAD.sub('', src_path) 191 | 192 | return _new_path and [(str(_new_path),)] 193 | 194 | 195 | @contextmanager 196 | def tmpdir(): 197 | d = tempfile.mkdtemp() 198 | try: 199 | yield Path(d) 200 | finally: 201 | shutil.rmtree(d) 202 | 203 | 204 | def _move_dir(src: str, dst: str, exists: bool): 205 | if exists: 206 | shutil.rmtree(dst) 207 | shutil.move(src, dst) 208 | 209 | 210 | def fast_move(src_dir: Path, dst_dir: Path): 211 | """ 212 | Move all files and directories from src_dir to dst_dir, files are moved first. This tries to be relatively fast. 213 | """ 214 | 215 | to_move = [] 216 | to_rename = [] 217 | for src_path in src_dir.iterdir(): 218 | if src_path.is_file(): 219 | to_rename.append((src_path, dst_dir / src_path.relative_to(src_dir))) 220 | else: 221 | assert src_path.is_dir(), src_path 222 | dst = dst_dir / src_path.relative_to(src_dir) 223 | to_move.append((str(src_path), str(dst), dst.exists())) 224 | 225 | dst_dir.mkdir(parents=True, exist_ok=True) 226 | s = time() 227 | # files in the root of src_dir are moved first, these are generally the scss files which 228 | # should be updated first to avoid styles not changing when a browser reloads 229 | for src, dst in to_rename: 230 | src.rename(dst) 231 | for src, dst, exists in to_move: 232 | if exists: 233 | shutil.rmtree(dst) 234 | shutil.move(src, dst) 235 | logger.debug('filed from %s/ to %s/ in %0.1fms', src_dir, dst_dir, (time() - s) * 1000) 236 | 237 | 238 | def insert_hash(path: Path, content: Union[str, bytes], *, hash_length=7): 239 | """ 240 | Insert a hash based on the content into the path after the first dot. 241 | 242 | hash_length 7 matches git commit short references 243 | """ 244 | if isinstance(content, str): 245 | content = content.encode() 246 | hash_ = hashlib.md5(content).hexdigest()[:hash_length] 247 | if '.' in path.name: 248 | new_name = re.sub(r'\.', f'.{hash_}.', path.name, count=1) 249 | else: 250 | new_name = f'{path.name}.{hash_}' 251 | return path.with_name(new_name) 252 | 253 | 254 | KB, MB = 1024, 1024 ** 2 255 | 256 | 257 | def fmt_size(num): 258 | if num <= KB: 259 | return f'{num:0.0f}B' 260 | elif num <= MB: 261 | return f'{num / KB:0.1f}KB' 262 | else: 263 | return f'{num / MB:0.1f}MB' 264 | 265 | 266 | def is_relative_to(p1: Path, p2: Path) -> bool: 267 | try: 268 | p1.relative_to(p2) 269 | except ValueError: 270 | return False 271 | else: 272 | return True 273 | --------------------------------------------------------------------------------