├── requirements ├── install.txt ├── release.txt └── dev.txt ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── examples ├── complex │ ├── light.scss │ ├── widgets │ │ ├── _qwidget.scss │ │ ├── _qlineedit.scss │ │ └── _qpushbutton.scss │ ├── _base.scss │ ├── dark.scss │ └── _defaults.scss └── dummy.scss ├── MANIFEST.in ├── qtsass ├── __main__.py ├── watchers │ ├── __init__.py │ ├── snapshots.py │ ├── qt.py │ ├── polling.py │ └── api.py ├── __init__.py ├── importers.py ├── functions.py ├── cli.py ├── api.py └── conformers.py ├── AUTHORS.md ├── .mailmap ├── LICENSE.txt ├── tests ├── __init__.py ├── test_functions.py ├── test_watchers.py ├── test_api.py ├── test_cli.py └── test_conformers.py ├── setup.cfg ├── .authors.yml ├── run_checks_and_format.py ├── setup.py ├── RELEASE.md ├── .gitignore ├── README.md ├── CHANGELOG.md └── rever.xsh /requirements/install.txt: -------------------------------------------------------------------------------- 1 | libsass 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: spyder 2 | -------------------------------------------------------------------------------- /examples/complex/light.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | -------------------------------------------------------------------------------- /requirements/release.txt: -------------------------------------------------------------------------------- 1 | loghub 2 | twine 3 | wheel 4 | -------------------------------------------------------------------------------- /examples/complex/widgets/_qwidget.scss: -------------------------------------------------------------------------------- 1 | QWidget { 2 | background: $background; 3 | color: $primary; 4 | } 5 | -------------------------------------------------------------------------------- /examples/complex/_base.scss: -------------------------------------------------------------------------------- 1 | @import 'defaults'; 2 | @import 'widgets/qwidget'; 3 | @import 'widgets/qpushbutton'; 4 | @import 'widgets/qlineedit'; 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the README 2 | include *.md 3 | 4 | # Include the license file 5 | include LICENSE.txt 6 | 7 | # Include the data files 8 | recursive-include data * 9 | -------------------------------------------------------------------------------- /examples/complex/widgets/_qlineedit.scss: -------------------------------------------------------------------------------- 1 | QLineEdit { 2 | color: $primary; 3 | padding: $text-padding; 4 | border: 0; 5 | border-bottom: 1px solid $primary; 6 | } 7 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | codecov 2 | flaky 3 | isort==4.3.15 4 | pycodestyle==2.5.0 5 | pydocstyle==3.0.0 6 | PySide2; python_version=="3.7" 7 | pytest 8 | pytest-cov 9 | yapf==0.26 10 | -------------------------------------------------------------------------------- /examples/complex/dark.scss: -------------------------------------------------------------------------------- 1 | // Change default values 2 | 3 | $background: rgb(35, 35, 35); 4 | $primary: rgb(255, 255, 255); 5 | 6 | 7 | // Import base style 8 | 9 | @import 'base'; 10 | -------------------------------------------------------------------------------- /examples/complex/widgets/_qpushbutton.scss: -------------------------------------------------------------------------------- 1 | QPushButton { 2 | background: $background; 3 | color: $accent; 4 | padding: $text-padding; 5 | border: 0; 6 | border-radius: $border-radius; 7 | } 8 | 9 | QPushButton:hover, 10 | QPushButton:focus { 11 | background: $accent; 12 | color: $background; 13 | } 14 | -------------------------------------------------------------------------------- /examples/complex/_defaults.scss: -------------------------------------------------------------------------------- 1 | // Fonts 2 | $font-stack: Helvetica, sans-serif !default; 3 | 4 | // Colors 5 | $background: rgb(255, 255, 255) !default; 6 | $primary: rgb(35, 35, 35) !default; 7 | $accent: rgb(35, 75, 135) !default; 8 | 9 | // Paddings 10 | $text-padding: 16px 8px 16px 8px !default; 11 | 12 | // Borders 13 | $border-radius: 2px !default; 14 | -------------------------------------------------------------------------------- /qtsass/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """qtsass command line interface.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import 14 | 15 | # Local imports 16 | from qtsass import cli 17 | 18 | 19 | # yapf: enable 20 | 21 | if __name__ == '__main__': 22 | cli.main() 23 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | The qtsass project has some great contributors! They are: 4 | 5 | - [Andrey Galkin](https://github.com/tmpfork) 6 | - [C.A.M. Gerlach](https://github.com/CAM-Gerlach) 7 | - [Carlos Cordoba](https://github.com/ccordoba12) 8 | - [Dan Bradham](https://github.com/danbradham) 9 | - [Eric Werner](https://github.com/ewerybody) 10 | - [Gonzalo Peña-Castellanos](https://github.com/goanpeca) 11 | - [Matthew Joyce](https://github.com/matsjoyce) 12 | - [Yann Lanthony](https://github.com/yann-lty) 13 | 14 | 15 | These have been sorted alphabetically. The full list of contributors can be found at: https://github.com/spyder-ide/qtsass/graphs/contributors 16 | -------------------------------------------------------------------------------- /examples/dummy.scss: -------------------------------------------------------------------------------- 1 | 2 | // This comment will not appear in generated css file 3 | /* This comment will appear in generated css file */ 4 | 5 | // !editable is not valid sass/css but is tolerated in qtsass since widespread in Qt stylesheets. 6 | QComboBox:!editable:on, QComboBox::drop-down:editable:on 7 | { 8 | color: blue; 9 | } 10 | 11 | // standard qss qlineargradient syntax works 12 | QListView::item:selected{ 13 | background-color: qlineargradient( 14 | x1: 0, 15 | y1: 0, 16 | x2: 0, 17 | y2:1, 18 | stop: 0.2 #3f3f3f, 19 | stop: 0.8 red 20 | ); 21 | } 22 | 23 | // You may also use QtSASS syntax directly 24 | QTreeView::item:selected{ 25 | $start: 0.2; 26 | $stops: $start #3f3f3f, $start + 0.6 red; 27 | background-color: qlineargradient(0, 0, 0, 1, $stops); 28 | color: rgba(255, 10, 10, 0.5); 29 | } 30 | -------------------------------------------------------------------------------- /qtsass/watchers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """The qtsass Watcher is responsible for watching and recompiling sass. 10 | 11 | The default Watcher is the QtWatcher. If Qt is unavailable we fallback to the 12 | PollingWatcher. 13 | """ 14 | 15 | # yapf: disable 16 | 17 | from __future__ import absolute_import 18 | 19 | # Local imports 20 | from qtsass.watchers.polling import PollingWatcher 21 | 22 | 23 | try: 24 | from qtsass.watchers.qt import QtWatcher 25 | except ImportError: 26 | QtWatcher = None 27 | 28 | 29 | # yapf: enable 30 | 31 | Watcher = QtWatcher or PollingWatcher 32 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by rever: https://regro.github.io/rever-docs/ 2 | # This prevent git from showing duplicates with various logging commands. 3 | # See the git documentation for more details. The syntax is: 4 | # 5 | # good-name bad-name 6 | # 7 | # You can skip bad-name if it is the same as good-name and is unique in the repo. 8 | # 9 | # This file is up-to-date if the command git log --format="%aN <%aE>" | sort -u 10 | # gives no duplicates. 11 | 12 | Andrey Galkin Andrey Galkin 13 | C.A.M. Gerlach 14 | Carlos Cordoba 15 | Dan Bradham 16 | Eric Werner 17 | Gonzalo Peña-Castellanos Gonzalo Pena-Castellanos 18 | Gonzalo Peña-Castellanos goanpeca 19 | Matthew Joyce 20 | Yann Lanthony yann.lanthony 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Yann Lanthony 4 | Copyright (c) 2017-2018 Spyder Project Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | 10 | # Standard library imports 11 | from os.path import dirname, join, normpath 12 | import os 13 | import time 14 | 15 | 16 | PROJECT_DIR = normpath(dirname(dirname(__file__))) 17 | EXAMPLES_DIR = normpath(join(PROJECT_DIR, 'examples')) 18 | 19 | 20 | def example(*paths): 21 | """Get path to an example.""" 22 | 23 | return normpath(join(dirname(__file__), '..', 'examples', *paths)) 24 | 25 | 26 | def touch(file): 27 | """Touch a file.""" 28 | 29 | with open(str(file), 'a'): 30 | os.utime(str(file), None) 31 | 32 | 33 | def await_condition(condition, timeout=20, qt_app=None): 34 | """Return True if a condition is met in the given timeout period""" 35 | 36 | for _ in range(timeout): 37 | if qt_app: 38 | # pump event loop while waiting for condition 39 | qt_app.processEvents() 40 | if condition(): 41 | return True 42 | time.sleep(0.1) 43 | return False 44 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file(s) in the wheel. 3 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 4 | license_files = LICENSE.txt 5 | 6 | # pydocstyle 7 | # http://www.pydocstyle.org/en/latest/usage.html 8 | # [pydocstyle] 9 | 10 | # pycodestyle 11 | # http://pycodestyle.pycqa.org/en/latest/intro.html#configuration 12 | [pycodestyle] 13 | max-line-length = 79 14 | statistics = True 15 | 16 | # yapf 17 | # https://github.com/google/yapf#formatting-style 18 | [yapf:style] 19 | based_on_style = pep8 20 | column_limit = 79 21 | spaces_before_comment = 2 22 | allow_multiline_lambdas = true 23 | dedent_closing_brackets = true 24 | blank_line_before_nested_class_or_def = false 25 | 26 | # isort 27 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 28 | [isort] 29 | from_first = true 30 | import_heading_stdlib = Standard library imports 31 | import_heading_firstparty = Local imports 32 | import_heading_localfolder = Local imports 33 | import_heading_thirdparty = Third party imports 34 | indent = ' ' 35 | known_first_party = qtsass 36 | known_third_party = libsass,pytest,setuptools,watchdog 37 | default_section = THIRDPARTY 38 | line_length = 79 39 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 40 | lines_after_imports = 2 41 | skip = venv 42 | multi_line_output = 3 43 | include_trailing_comma = true 44 | -------------------------------------------------------------------------------- /.authors.yml: -------------------------------------------------------------------------------- 1 | - name: Andrey Galkin 2 | email: andrey@futoin.eu 3 | alternate_emails: 4 | - andrey@futoin.org 5 | num_commits: 5 6 | first_commit: 2019-02-25 16:41:43 7 | github: tmpfork 8 | - name: Eric Werner 9 | email: ewerybody+github@gmail.com 10 | num_commits: 16 11 | first_commit: 2019-08-29 09:56:15 12 | github: ewerybody 13 | - name: Matthew Joyce 14 | email: matsjoyce@gmail.com 15 | num_commits: 2 16 | first_commit: 2019-07-25 07:53:58 17 | github: matsjoyce 18 | - name: Dan Bradham 19 | email: danielbradham@gmail.com 20 | num_commits: 66 21 | first_commit: 2018-05-01 16:28:41 22 | github: danbradham 23 | - name: Gonzalo Peña-Castellanos 24 | email: goanpeca@gmail.com 25 | aliases: 26 | - Gonzalo Pena-Castellanos 27 | - goanpeca 28 | num_commits: 27 29 | first_commit: 2017-11-22 18:15:12 30 | github: goanpeca 31 | - name: Carlos Cordoba 32 | email: ccordoba12@gmail.com 33 | num_commits: 3 34 | first_commit: 2019-04-26 04:36:01 35 | github: ccordoba12 36 | - name: C.A.M. Gerlach 37 | email: widenetservices@gmail.com 38 | num_commits: 3 39 | first_commit: 2018-06-11 16:08:54 40 | github: CAM-Gerlach 41 | - name: Yann Lanthony 42 | email: yann.lanthony@gmail.com 43 | aliases: 44 | - yann.lanthony 45 | num_commits: 22 46 | first_commit: 2015-08-16 05:22:05 47 | github: yann-lty 48 | - name: Daniel Althviz Moré 49 | email: d.althviz10@uniandes.edu.co 50 | aliases: 51 | - dalthviz 52 | - Daniel Althviz 53 | github: dalthviz 54 | - name: Sebastian Weigand 55 | email: s.weigand.phy@gmail.com 56 | aliases: 57 | - s-weigand 58 | github: s-weigand -------------------------------------------------------------------------------- /qtsass/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """ 10 | The SASS language brings countless amazing features to CSS. 11 | 12 | Besides being used in web development, CSS is also the way to stylize Qt-based 13 | desktop applications. However, Qt's CSS has a few variations that prevent the 14 | direct use of SASS compiler. 15 | 16 | The purpose of qtsass is to fill the gap between SASS and Qt-CSS by handling 17 | those variations. 18 | """ 19 | 20 | # yapf: disable 21 | 22 | from __future__ import absolute_import 23 | 24 | # Standard library imports 25 | import logging 26 | 27 | # Local imports 28 | from qtsass.api import ( 29 | compile, 30 | compile_dirname, 31 | compile_filename, 32 | enable_logging, 33 | watch, 34 | ) 35 | 36 | 37 | # yapf: enable 38 | 39 | # Setup Logging 40 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 41 | enable_logging() 42 | 43 | # Constants 44 | __version__ = '0.5.0.dev0' 45 | 46 | 47 | def _to_version_info(version): 48 | """Convert a version string to a number and string tuple.""" 49 | parts = [] 50 | for part in version.split('.'): 51 | try: 52 | part = int(part) 53 | except ValueError: 54 | pass 55 | 56 | parts.append(part) 57 | 58 | return tuple(parts) 59 | 60 | 61 | VERSION_INFO = _to_version_info(__version__) 62 | -------------------------------------------------------------------------------- /run_checks_and_format.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Run checks and format code.""" 10 | 11 | # yapf: disable 12 | 13 | # Standard library imports 14 | from subprocess import PIPE, Popen 15 | import sys 16 | 17 | 18 | # yapf: enable 19 | 20 | # Constants 21 | COMMANDS = [ 22 | ['pydocstyle', 'qtsass'], 23 | ['pycodestyle', 'qtsass'], 24 | ['yapf', 'qtsass', '--in-place', '--recursive'], 25 | ['isort', '-y'], 26 | ] 27 | 28 | 29 | def run_process(cmd_list): 30 | """Run popen process.""" 31 | 32 | try: 33 | p = Popen(cmd_list, stdout=PIPE, stderr=PIPE) 34 | except OSError: 35 | raise OSError('Could not call command list: "%s"' % cmd_list) 36 | 37 | out, err = p.communicate() 38 | out = out.decode() 39 | err = err.decode() 40 | return out, err 41 | 42 | 43 | def repo_changes(): 44 | """Check if repo files changed.""" 45 | out, _err = run_process(['git', 'status', '--short']) 46 | out_lines = [l for l in out.split('\n') if l.strip()] 47 | return out_lines 48 | 49 | 50 | def run(): 51 | """Run linters and formatters.""" 52 | 53 | for cmd_list in COMMANDS: 54 | cmd_str = ' '.join(cmd_list) 55 | print('\nRunning: ' + cmd_str) 56 | 57 | out, err = run_process(cmd_list) 58 | if out: 59 | print(out) 60 | if err: 61 | print(err) 62 | 63 | out_lines = repo_changes() 64 | if out_lines: 65 | print('\nPlease run the linter and formatter script!') 66 | print('\n'.join(out_lines)) 67 | code = 1 68 | else: 69 | print('\nAll checks passed!') 70 | code = 0 71 | 72 | print('\n') 73 | sys.exit(code) 74 | 75 | 76 | if __name__ == '__main__': 77 | run() 78 | -------------------------------------------------------------------------------- /qtsass/watchers/snapshots.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Contains the fallback implementation of the Watcher api.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | # Standard library imports 16 | import os 17 | 18 | # Local imports 19 | from qtsass.importers import norm_path 20 | 21 | 22 | # yapf: enable 23 | 24 | 25 | def take(dir_or_file, depth=3): 26 | """Return a dict mapping files and folders to their mtimes.""" 27 | if os.path.isfile(dir_or_file): 28 | path = norm_path(dir_or_file) 29 | return {path: os.path.getmtime(path)} 30 | 31 | if not os.path.isdir(dir_or_file): 32 | return {} 33 | 34 | snapshot = {} 35 | base_depth = len(norm_path(dir_or_file).split('/')) 36 | 37 | for root, subdirs, files in os.walk(dir_or_file): 38 | 39 | path = norm_path(root) 40 | if len(path.split('/')) - base_depth == depth: 41 | subdirs[:] = [] 42 | 43 | snapshot[path] = os.path.getmtime(path) 44 | for f in files: 45 | path = norm_path(root, f) 46 | snapshot[path] = os.path.getmtime(path) 47 | 48 | return snapshot 49 | 50 | 51 | def diff(prev_snapshot, next_snapshot): 52 | """Return a dict containing changes between two snapshots.""" 53 | changes = {} 54 | for path in set(prev_snapshot.keys()) | set(next_snapshot.keys()): 55 | if path in prev_snapshot and path not in next_snapshot: 56 | changes[path] = 'Deleted' 57 | elif path not in prev_snapshot and path in next_snapshot: 58 | changes[path] = 'Created' 59 | else: 60 | prev_mtime = prev_snapshot[path] 61 | next_mtime = next_snapshot[path] 62 | if next_mtime > prev_mtime: 63 | changes[path] = 'Changed' 64 | return changes 65 | -------------------------------------------------------------------------------- /qtsass/importers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Libsass importers.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import 14 | 15 | # Standard library imports 16 | import os 17 | 18 | # Local imports 19 | from qtsass.conformers import scss_conform 20 | 21 | 22 | # yapf: enable 23 | 24 | 25 | def norm_path(*parts): 26 | """Normalize path.""" 27 | return os.path.normpath(os.path.join(*parts)).replace('\\', '/') 28 | 29 | 30 | def qss_importer(*include_paths): 31 | """ 32 | Return function which conforms imported qss files to valid scss. 33 | 34 | This fucntion is to be used as an importer for sass.compile. 35 | 36 | :param include_paths: Directorys containing scss, css, and sass files. 37 | """ 38 | include_paths 39 | 40 | def find_file(import_file): 41 | # Create partial import filename 42 | dirname, basename = os.path.split(import_file) 43 | if dirname: 44 | import_partial_file = '/'.join([dirname, '_' + basename]) 45 | else: 46 | import_partial_file = '_' + basename 47 | 48 | # Build potential file paths for @import "import_file" 49 | potential_files = [] 50 | for ext in ['', '.scss', '.css', '.sass']: 51 | full_name = import_file + ext 52 | partial_name = import_partial_file + ext 53 | potential_files.append(full_name) 54 | potential_files.append(partial_name) 55 | for path in include_paths: 56 | potential_files.append(norm_path(path, full_name)) 57 | potential_files.append(norm_path(path, partial_name)) 58 | 59 | # Return first existing potential file 60 | for potential_file in potential_files: 61 | if os.path.isfile(potential_file): 62 | return potential_file 63 | 64 | return None 65 | 66 | def import_and_conform_file(import_file): 67 | """Return base file and conformed scss file.""" 68 | real_import_file = find_file(import_file) 69 | with open(real_import_file, 'r') as f: 70 | import_str = f.read() 71 | 72 | return [(import_file, scss_conform(import_str))] 73 | 74 | return import_and_conform_file 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Setup script for qtsass.""" 10 | 11 | # Standard library imports 12 | from io import open 13 | import ast 14 | import os 15 | 16 | # Third party imports 17 | from setuptools import find_packages, setup 18 | 19 | 20 | # Constants 21 | HERE = os.path.abspath(os.path.dirname(__file__)) 22 | 23 | 24 | def get_version(module='qtsass'): 25 | """Get version.""" 26 | with open(os.path.join(HERE, module, '__init__.py'), 'r') as f: 27 | data = f.read() 28 | 29 | lines = data.split('\n') 30 | for line in lines: 31 | if line.startswith('__version__'): 32 | version = ast.literal_eval(line.split('=')[-1].strip()) 33 | break 34 | 35 | return version 36 | 37 | 38 | def get_description(): 39 | """Get long description.""" 40 | with open(os.path.join(HERE, 'README.md'), 'r', encoding='utf-8') as f: 41 | data = f.read() 42 | 43 | return data 44 | 45 | 46 | setup( 47 | name='qtsass', 48 | version=get_version(), 49 | description='Compile SCSS files to valid Qt stylesheets.', 50 | long_description=get_description(), 51 | long_description_content_type='text/markdown', 52 | author='Yann Lanthony', 53 | maintainer='The Spyder Project Contributors', 54 | maintainer_email='qtsass@spyder-ide.org', 55 | url='https://github.com/spyder-ide/qtsass', 56 | license='MIT', 57 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 58 | entry_points={ 59 | 'console_scripts': [ 60 | 'qtsass = qtsass.cli:main' 61 | ] 62 | }, 63 | classifiers=( 64 | 'Development Status :: 5 - Production/Stable', 65 | 'Intended Audience :: Developers', 66 | 'License :: OSI Approved :: MIT License', 67 | 'Natural Language :: English', 68 | 'Operating System :: OS Independent', 69 | 'Programming Language :: Python', 70 | 'Programming Language :: Python :: 3', 71 | 'Programming Language :: Python :: 3.7', 72 | 'Programming Language :: Python :: 3.8', 73 | 'Programming Language :: Python :: 3.9', 74 | 'Programming Language :: Python :: 3.10', 75 | 'Topic :: Software Development :: Build Tools', 76 | 'Topic :: Software Development :: Libraries :: Python Modules', 77 | ), 78 | install_requires=[ 79 | 'libsass>=0.22.0', 80 | ], 81 | python_requires='>=3.7', 82 | keywords='qt sass qtsass scss css qss stylesheets', 83 | ) 84 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Test qtsass custom functions.""" 10 | 11 | from __future__ import absolute_import 12 | 13 | # Standard library imports 14 | import unittest 15 | 16 | # Local imports 17 | from qtsass.api import compile 18 | 19 | 20 | class BaseCompileTest(unittest.TestCase): 21 | def compile_scss(self, string): 22 | # NOTE: revise for better future compatibility 23 | wstr = '*{{t: {0};}}'.format(string) 24 | res = compile(wstr) 25 | return res.replace('* {\n t: ', '').replace('; }\n', '') 26 | 27 | 28 | class TestRgbaFunc(BaseCompileTest): 29 | def test_rgba(self): 30 | self.assertEqual( 31 | self.compile_scss('rgba(0, 1, 2, 0.3)'), 32 | 'rgba(0, 1, 2, 30%)' 33 | ) 34 | 35 | def test_rgba_percentage_alpha(self): 36 | result = self.compile_scss('rgba(255, 0, 125, 75%)') 37 | self.assertEqual(result, 'rgba(255, 0, 125, 75%)') 38 | 39 | def test_rgba_8bit_int_alpha(self): 40 | for in_val, out_val in ((0, 0), (128, 50), (255, 100)): 41 | result = self.compile_scss('rgba(255, 0, 125, %i)' % in_val) 42 | self.assertEqual(result, 'rgba(255, 0, 125, %i%%)' % out_val) 43 | 44 | 45 | class TestQLinearGradientFunc(BaseCompileTest): 46 | def test_color(self): 47 | self.assertEqual( 48 | self.compile_scss('qlineargradient(1, 2, 3, 4, (0 red, 1 blue))'), 49 | 'qlineargradient(x1: 1.0, y1: 2.0, x2: 3.0, y2: 4.0, ' 50 | 'stop: 0.0 rgba(255, 0, 0, 100%), stop: 1.0 rgba(0, 0, 255, 100%))' 51 | ) 52 | 53 | def test_rgba(self): 54 | self.assertEqual( 55 | self.compile_scss('qlineargradient(1, 2, 3, 4, (0 red, 0.2 rgba(5, 6, 7, 0.8)))'), 56 | 'qlineargradient(x1: 1.0, y1: 2.0, x2: 3.0, y2: 4.0, ' 57 | 'stop: 0.0 rgba(255, 0, 0, 100%), stop: 0.2 rgba(5, 6, 7, 80%))' 58 | ) 59 | 60 | 61 | class TestQRadialGradientFunc(BaseCompileTest): 62 | def test_color(self): 63 | self.assertEqual( 64 | self.compile_scss('qradialgradient(pad, 1, 2, 1, 3, 4, (0 red, 1 blue))'), 65 | 'qradialgradient(spread: pad, cx: 1.0, cy: 2.0, radius: 1.0, fx: 3.0, fy: 4.0, ' 66 | 'stop: 0.0 rgba(255, 0, 0, 100%), stop: 1.0 rgba(0, 0, 255, 100%))' 67 | ) 68 | 69 | def test_rgba(self): 70 | self.assertEqual( 71 | self.compile_scss('qradialgradient(pad, 1, 2, 1, 3, 4, (0 red, 0.2 rgba(5, 6, 7, 0.8)))'), 72 | 'qradialgradient(spread: pad, cx: 1.0, cy: 2.0, radius: 1.0, fx: 3.0, fy: 4.0, ' 73 | 'stop: 0.0 rgba(255, 0, 0, 100%), stop: 0.2 rgba(5, 6, 7, 80%))' 74 | ) 75 | 76 | 77 | if __name__ == "__main__": 78 | unittest.main(verbosity=2) 79 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | ## Using rever 4 | 5 | You need to have `conda` install since the process relies on conda environments. 6 | 7 | Make sure your current environment has [rever](https://regro.github.io/rever-docs/) installed. 8 | 9 | ```bash 10 | conda install rever -c conda-forge 11 | ``` 12 | 13 | Run checks before to make sure things are in order. 14 | 15 | ```bash 16 | rever check 17 | ``` 18 | 19 | Delete the `rever/` folder to start a clean release. 20 | 21 | ```bash 22 | rm -rf rever/ 23 | ``` 24 | 25 | Run rever with the type version (major|minor|patch|MAJOR.MINOR.PATCH) to update. 26 | 27 | ### Major release 28 | 29 | If the current version is `3.0.0.dev0`, running: 30 | 31 | ```bash 32 | rever major 33 | ``` 34 | 35 | Will produce version `4.0.0` and update the dev version to `4.0.0.dev0` 36 | 37 | ### Minor release 38 | 39 | If the current version is `3.0.0.dev0`, running: 40 | 41 | ```bash 42 | rever minor 43 | ``` 44 | 45 | Will produce version `3.1.0` and update the dev version to `3.1.0.dev0` 46 | 47 | ### Patch release 48 | 49 | If the current version is `3.0.0.dev0`, running: 50 | 51 | ```bash 52 | rever patch 53 | ``` 54 | 55 | Will produce version `3.0.1` and update the dev version to `3.0.1.dev0` 56 | 57 | ### MAJOR.MINOR.PATCH release 58 | 59 | If the current version is `3.0.0.dev0`, running: 60 | 61 | ```bash 62 | rever 5.0.1 63 | ``` 64 | 65 | Will produce version `5.0.1` and update the dev version to `5.0.1.dev0` 66 | 67 | ### Important 68 | 69 | - In case some of the steps appear as completed, delete the `rever` folder. 70 | 71 | ```bash 72 | rm -rf rever/ 73 | ``` 74 | 75 | - Some of the intermediate steps may ask for feedback, like checking the changelog. 76 | 77 | ## Semi-automatic process using Git and GitHub actions 78 | 79 | - Ensure you have the latest version from upstream and update your fork 80 | 81 | ```bash 82 | git pull upstream master 83 | git push origin master 84 | ``` 85 | 86 | - Clean the repo (select option 1) 87 | 88 | ```bash 89 | git clean -xfdi 90 | ``` 91 | 92 | - Update `CHANGELOG.md` using loghub 93 | 94 | ```bash 95 | loghub spyder-ide/qtsass -m 96 | ``` 97 | 98 | - Update version in `__init__.py` (set release version, remove 'dev0') 99 | 100 | - Commit and push changes 101 | 102 | ```bash 103 | git add . 104 | git commit -m "Release X.X.X" 105 | git push upstream master 106 | git push origin master 107 | ``` 108 | 109 | - Make a [new release](https://github.com/spyder-ide/qtsass/releases) with tag name `vX.X.X` 110 | 111 | - Check that [the CI workflow](https://github.com/spyder-ide/qtsass/actions) for `vX.X.X` 112 | successfully deployed the new release 113 | 114 | - Update `__init__.py` (add 'dev0' and increment minor) 115 | 116 | - Commit and push changes 117 | 118 | ```bash 119 | git add . 120 | git commit -m "Back to work" 121 | git push upstream master 122 | git push origin master 123 | ``` 124 | 125 | ## To release a new version of **qtsass** on conda-forge 126 | 127 | - Update recipe on the [qtsass feedstock](https://github.com/conda-forge/qtsass-feedstock) 128 | -------------------------------------------------------------------------------- /qtsass/watchers/qt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Contains the Qt implementation of the Watcher api.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import 14 | 15 | # Local imports 16 | from qtsass.watchers.polling import PollingWatcher 17 | 18 | 19 | # We cascade through Qt bindings here rather than relying on a comprehensive 20 | # Qt compatability library like qtpy or Qt.py. This prevents us from forcing a 21 | # specific compatability library on users. 22 | QT_BINDING = None 23 | if not QT_BINDING: 24 | try: 25 | from PySide2.QtWidgets import QApplication 26 | from PySide2.QtCore import QObject, Signal 27 | QT_BINDING = 'pyside2' 28 | except ImportError: 29 | pass 30 | if not QT_BINDING: 31 | try: 32 | from PyQt5.QtWidgets import QApplication 33 | from PyQt5.QtCore import QObject 34 | from PyQt5.QtCore import pyqtSignal as Signal 35 | QT_BINDING = 'pyqt5' 36 | except ImportError: 37 | pass 38 | if not QT_BINDING: 39 | try: 40 | from PySide.QtGui import QApplication 41 | from PySide2.QtCore import QObject, Signal 42 | QT_BINDING == 'pyside' 43 | except ImportError: 44 | pass 45 | if not QT_BINDING: 46 | from PyQt4.QtGui import QApplication 47 | from PyQt4.QtCore import QObject 48 | from PyQt4.QtCore import pyqtSignal as Signal 49 | QT_BINDING == 'pyqt4' 50 | 51 | 52 | # yapf: enable 53 | 54 | 55 | class QtDispatcher(QObject): 56 | """Used by QtWatcher to dispatch callbacks in the main ui thread.""" 57 | 58 | signal = Signal() 59 | 60 | 61 | class QtWatcher(PollingWatcher): 62 | """The Qt implementation of the Watcher api. 63 | 64 | Subclasses PollingWatcher but dispatches :meth:`compile_and_dispatch` 65 | using a Qt Signal to ensure that these calls are executed in the main ui 66 | thread. We aren't using a QFileSystemWatcher because it fails to report 67 | changes in certain circumstances. 68 | """ 69 | 70 | _qt_binding = QT_BINDING 71 | 72 | def setup(self): 73 | """Set up QtWatcher.""" 74 | super(QtWatcher, self).setup() 75 | self._qtdispatcher = None 76 | 77 | @property 78 | def qtdispatcher(self): 79 | """Get the QtDispatcher.""" 80 | if self._qtdispatcher is None: 81 | self._qtdispatcher = QtDispatcher() 82 | self._qtdispatcher.signal.connect(self.compile_and_dispatch) 83 | return self._qtdispatcher 84 | 85 | def on_change(self): 86 | """Call when a change is detected.""" 87 | self._log.debug('Change detected...') 88 | 89 | # If a QApplication event loop has not been started 90 | # call compile_and_dispatch in the current thread. 91 | if not QApplication.instance(): 92 | return super(PollingWatcher, self).compile_and_dispatch() 93 | 94 | # Create and use a QtDispatcher to ensure compile and any 95 | # connected callbacks get executed in the main gui thread. 96 | self.qtdispatcher.signal.emit() 97 | -------------------------------------------------------------------------------- /qtsass/functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Libsass functions.""" 10 | 11 | # yapf: disable 12 | 13 | # Third party imports 14 | import sass 15 | 16 | 17 | # yapf: enable 18 | 19 | 20 | def rgba(r, g, b, a): 21 | """Convert r,g,b,a values to standard format. 22 | 23 | Where `a` is alpha! In CSS alpha can be given as: 24 | * float from 0.0 (fully transparent) to 1.0 (opaque) 25 | In Qt or qss that is: 26 | * int from 0 (fully transparent) to 255 (opaque) 27 | A percentage value 0% (fully transparent) to 100% (opaque) works 28 | in BOTH systems the same way! 29 | """ 30 | result = 'rgba({}, {}, {}, {}%)' 31 | if isinstance(r, sass.SassNumber): 32 | if a.unit == '%': 33 | alpha = a.value 34 | elif a.value > 1.0: 35 | # A value from 0 to 255 is coming in, convert to % 36 | alpha = a.value / 2.55 37 | else: 38 | alpha = a.value * 100 39 | return result.format( 40 | int(r.value), 41 | int(g.value), 42 | int(b.value), 43 | int(alpha), 44 | ) 45 | elif isinstance(r, float): 46 | return result.format(int(r), int(g), int(b), int(a * 100)) 47 | 48 | 49 | def rgba_from_color(color): 50 | """ 51 | Conform rgba. 52 | 53 | :type color: sass.SassColor 54 | """ 55 | # Inner rgba() call 56 | if not isinstance(color, sass.SassColor): 57 | return '{}'.format(color) 58 | 59 | return rgba(color.r, color.g, color.b, color.a) 60 | 61 | 62 | def qlineargradient(x1, y1, x2, y2, stops): 63 | """ 64 | Implement qss qlineargradient function for scss. 65 | 66 | :type x1: sass.SassNumber 67 | :type y1: sass.SassNumber 68 | :type x2: sass.SassNumber 69 | :type y2: sass.SassNumber 70 | :type stops: sass.SassList 71 | :return: 72 | """ 73 | stops_str = [] 74 | for stop in stops[0]: 75 | pos, color = stop[0] 76 | stops_str.append('stop: {} {}'.format( 77 | pos.value, 78 | rgba_from_color(color), 79 | )) 80 | template = 'qlineargradient(x1: {}, y1: {}, x2: {}, y2: {}, {})' 81 | return template.format(x1.value, y1.value, x2.value, y2.value, 82 | ', '.join(stops_str)) 83 | 84 | 85 | def qradialgradient(spread, cx, cy, radius, fx, fy, stops): 86 | """ 87 | Implement qss qradialgradient function for scss. 88 | 89 | :type spread: string 90 | :type cx: sass.SassNumber 91 | :type cy: sass.SassNumber 92 | :type radius: sass.SassNumber 93 | :type fx: sass.SassNumber 94 | :type fy: sass.SassNumber 95 | :type stops: sass.SassList 96 | :return: 97 | """ 98 | stops_str = [] 99 | for stop in stops[0]: 100 | pos, color = stop[0] 101 | stops_str.append('stop: {} {}'.format( 102 | pos.value, 103 | rgba_from_color(color), 104 | )) 105 | template = ('qradialgradient(' 106 | 'spread: {}, cx: {}, cy: {}, radius: {}, fx: {}, fy: {}, {}' 107 | ')') 108 | return template.format(spread, cx.value, cy.value, radius.value, fx.value, 109 | fy.value, ', '.join(stops_str)) 110 | -------------------------------------------------------------------------------- /qtsass/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """qtsass command line interface.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | # Standard library imports 16 | import argparse 17 | import logging 18 | import os 19 | import sys 20 | import time 21 | 22 | # Local imports 23 | from qtsass.api import ( 24 | compile, 25 | compile_dirname, 26 | compile_filename, 27 | enable_logging, 28 | watch, 29 | ) 30 | 31 | 32 | # yapf: enable 33 | 34 | _log = logging.getLogger(__name__) 35 | 36 | 37 | def create_parser(): 38 | """Create qtsass's cli parser.""" 39 | parser = argparse.ArgumentParser( 40 | prog='QtSASS', 41 | description='Compile a Qt compliant CSS file from a SASS stylesheet.', 42 | ) 43 | parser.add_argument( 44 | 'input', 45 | type=str, 46 | help='The SASS stylesheet file.', 47 | ) 48 | parser.add_argument( 49 | '-o', 50 | '--output', 51 | type=str, 52 | help='The path of the generated Qt compliant CSS file.', 53 | ) 54 | parser.add_argument( 55 | '-w', 56 | '--watch', 57 | action='store_true', 58 | help='If set, recompile when the source file changes.', 59 | ) 60 | parser.add_argument( 61 | '-d', 62 | '--debug', 63 | action='store_true', 64 | help='Set the logging level to DEBUG.', 65 | ) 66 | return parser 67 | 68 | 69 | def main(): 70 | """CLI entry point.""" 71 | args = create_parser().parse_args() 72 | 73 | # Setup CLI logging 74 | debug = os.environ.get('QTSASS_DEBUG', args.debug) 75 | if debug in ('1', 'true', 'True', 'TRUE', 'on', 'On', 'ON', True): 76 | level = logging.DEBUG 77 | else: 78 | level = logging.INFO 79 | enable_logging(level) 80 | 81 | # Add a StreamHandler 82 | handler = logging.StreamHandler() 83 | if level == logging.DEBUG: 84 | fmt = '%(levelname)-8s: %(name)s> %(message)s' 85 | handler.setFormatter(logging.Formatter(fmt)) 86 | logging.root.addHandler(handler) 87 | logging.root.setLevel(level) 88 | 89 | file_mode = os.path.isfile(args.input) 90 | dir_mode = os.path.isdir(args.input) 91 | 92 | if file_mode and not args.output: 93 | with open(args.input, 'r') as f: 94 | string = f.read() 95 | 96 | css = compile( 97 | string, 98 | include_paths=os.path.abspath(os.path.dirname(args.input)), 99 | ) 100 | print(css) 101 | sys.exit(0) 102 | 103 | elif file_mode: 104 | _log.debug('compile_filename({}, {})'.format(args.input, args.output)) 105 | compile_filename(args.input, args.output) 106 | 107 | elif dir_mode and not args.output: 108 | print('Error: missing required option: -o/--output') 109 | sys.exit(1) 110 | 111 | elif dir_mode: 112 | _log.debug('compile_dirname({}, {})'.format(args.input, args.output)) 113 | compile_dirname(args.input, args.output) 114 | 115 | else: 116 | print('Error: input must be a file or a directory') 117 | sys.exit(1) 118 | 119 | if args.watch: 120 | _log.info('qtsass is watching {}...'.format(args.input)) 121 | 122 | watcher = watch(args.input, args.output) 123 | watcher.start() 124 | try: 125 | while True: 126 | time.sleep(0.5) 127 | except KeyboardInterrupt: 128 | watcher.stop() 129 | watcher.join() 130 | sys.exit(0) 131 | -------------------------------------------------------------------------------- /qtsass/watchers/polling.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Contains the fallback implementation of the Watcher api.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | # Standard library imports 16 | import atexit 17 | import threading 18 | 19 | # Local imports 20 | from qtsass.watchers import snapshots 21 | from qtsass.watchers.api import Watcher 22 | 23 | 24 | # yapf: enable 25 | 26 | 27 | class PollingThread(threading.Thread): 28 | """A thread that fires a callback at an interval.""" 29 | 30 | def __init__(self, callback, interval): 31 | """Initialize the thread. 32 | 33 | :param callback: Callback function to repeat. 34 | :param interval: Number of seconds to sleep between calls. 35 | """ 36 | super(PollingThread, self).__init__() 37 | self.daemon = True 38 | self.callback = callback 39 | self.interval = interval 40 | self._shutdown = threading.Event() 41 | self._stopped = threading.Event() 42 | self._started = threading.Event() 43 | atexit.register(self.stop) 44 | 45 | @property 46 | def started(self): 47 | """Check if the thread has started.""" 48 | return self._started.is_set() 49 | 50 | @property 51 | def stopped(self): 52 | """Check if the thread has stopped.""" 53 | return self._stopped.is_set() 54 | 55 | @property 56 | def shutdown(self): 57 | """Check if the thread has shutdown.""" 58 | return self._shutdown.is_set() 59 | 60 | def stop(self): 61 | """Set the shutdown event for this thread and wait for it to stop.""" 62 | if not self.started and not self.shutdown: 63 | return 64 | 65 | self._shutdown.set() 66 | self._stopped.wait() 67 | 68 | def run(self): 69 | """Threads main loop.""" 70 | try: 71 | self._started.set() 72 | 73 | while True: 74 | self.callback() 75 | if self._shutdown.wait(self.interval): 76 | break 77 | 78 | finally: 79 | self._stopped.set() 80 | 81 | 82 | class PollingWatcher(Watcher): 83 | """Polls a directory recursively for changes. 84 | 85 | Detects file and directory changes, deletions, and creations. Recursion 86 | depth is limited to 2 levels. We use a limit because the scss file we're 87 | watching for changes could be sitting in the root of a project rather than 88 | a dedicated scss directory. That could lead to snapshots taking too long 89 | to build and diff. It's probably safe to assume that users aren't nesting 90 | scss deeper than a couple of levels. 91 | """ 92 | 93 | def setup(self): 94 | """Set up the PollingWatcher. 95 | 96 | A PollingThread is created but not started. 97 | """ 98 | self._snapshot_depth = 2 99 | self._snapshot = snapshots.take(self._watch_dir, self._snapshot_depth) 100 | self._thread = PollingThread(self.run, interval=1) 101 | 102 | def start(self): 103 | """Start the PollingThread.""" 104 | self._thread.start() 105 | 106 | def stop(self): 107 | """Stop the PollingThread.""" 108 | self._thread.stop() 109 | 110 | def join(self): 111 | """Wait for the PollingThread to finish. 112 | 113 | You should always call stop before join. 114 | """ 115 | self._thread.join() 116 | 117 | def run(self): 118 | """Take a new snapshot and call on_change when a change is detected. 119 | 120 | Called repeatedly by the PollingThread. 121 | """ 122 | next_snapshot = snapshots.take(self._watch_dir, self._snapshot_depth) 123 | changes = snapshots.diff(self._snapshot, next_snapshot) 124 | if changes: 125 | self._snapshot = next_snapshot 126 | self.on_change() 127 | -------------------------------------------------------------------------------- /qtsass/watchers/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """The filesystem watcher api.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import 14 | 15 | # Standard library imports 16 | import functools 17 | import logging 18 | import time 19 | 20 | 21 | _log = logging.getLogger(__name__) 22 | 23 | 24 | def retry(n, interval=0.1): 25 | """Retry a function or method n times before raising an exception. 26 | 27 | :param n: Number of times to retry 28 | :param interval: Time to sleep before attempts 29 | """ 30 | def decorate(fn): 31 | @functools.wraps(fn) 32 | def attempt(*args, **kwargs): 33 | attempts = 0 34 | while True: 35 | try: 36 | return fn(*args, **kwargs) 37 | except Exception: 38 | attempts += 1 39 | if n <= attempts: 40 | raise 41 | time.sleep(interval) 42 | 43 | return attempt 44 | 45 | return decorate 46 | 47 | # yapf: enable 48 | 49 | 50 | class Watcher(object): 51 | """Watcher base class. 52 | 53 | Watchers monitor a file or directory and call the on_change method when a 54 | change occurs. The on_change method should trigger the compiler function 55 | passed in during construction and dispatch the result to all connected 56 | callbacks. 57 | 58 | Watcher implementations must inherit from this base class. Subclasses 59 | should perform any setup required in the setup method, rather than 60 | overriding __init__. 61 | """ 62 | 63 | def __init__(self, watch_dir, compiler, args=None, kwargs=None): 64 | """Store initialization values and call Watcher.setup.""" 65 | self._watch_dir = watch_dir 66 | self._compiler = compiler 67 | self._args = args or () 68 | self._kwargs = kwargs or {} 69 | self._callbacks = set() 70 | self._log = _log 71 | self.setup() 72 | 73 | def setup(self): 74 | """Perform any setup required here. 75 | 76 | Rather than implement __init__, subclasses can perform any setup in 77 | this method. 78 | """ 79 | return NotImplemented 80 | 81 | def start(self): 82 | """Start this Watcher.""" 83 | return NotImplemented 84 | 85 | def stop(self): 86 | """Stop this Watcher.""" 87 | return NotImplemented 88 | 89 | def join(self): 90 | """Wait for this Watcher to finish.""" 91 | return NotImplemented 92 | 93 | @retry(5) 94 | def compile(self): 95 | """Call the Watcher's compiler.""" 96 | self._log.debug( 97 | 'Compiling sass...%s(*%s, **%s)', 98 | self._compiler, 99 | self._args, 100 | self._kwargs, 101 | ) 102 | return self._compiler(*self._args, **self._kwargs) 103 | 104 | def compile_and_dispatch(self): 105 | """Compile and dispatch the resulting css to connected callbacks.""" 106 | self._log.debug('Compiling and dispatching....') 107 | 108 | try: 109 | css = self.compile() 110 | except Exception: 111 | self._log.exception('Failed to compile...') 112 | return 113 | 114 | self.dispatch(css) 115 | 116 | def dispatch(self, css): 117 | """Dispatch css to connected callbacks.""" 118 | self._log.debug('Dispatching callbacks...') 119 | for callback in self._callbacks: 120 | callback(css) 121 | 122 | def on_change(self): 123 | """Call when a change is detected. 124 | 125 | Subclasses must call this method when they detect a change. Subclasses 126 | may also override this method in order to manually compile and dispatch 127 | callbacks. For example, a Qt implementation may use signals and slots 128 | to ensure that compiling and executing callbacks happens in the main 129 | GUI thread. 130 | """ 131 | self._log.debug('Change detected...') 132 | self.compile_and_dispatch() 133 | 134 | def connect(self, fn): 135 | """Connect a callback to this Watcher. 136 | 137 | All callbacks are called when a change is detected. Callbacks are 138 | passed the compiled css. 139 | """ 140 | self._log.debug('Connecting callback: %s', fn) 141 | self._callbacks.add(fn) 142 | 143 | def disconnect(self, fn): 144 | """Disconnect a callback from this Watcher.""" 145 | self._log.debug('Disconnecting callback: %s', fn) 146 | self._callbacks.discard(fn) 147 | -------------------------------------------------------------------------------- /tests/test_watchers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Test qtsass cli.""" 10 | 11 | from __future__ import absolute_import 12 | 13 | # Standard library imports 14 | from os.path import dirname, exists 15 | import os 16 | import shutil 17 | import sys 18 | import time 19 | 20 | # Third party imports 21 | import pytest 22 | 23 | # Local imports 24 | #Local imports 25 | from qtsass import compile_filename 26 | from qtsass.watchers import PollingWatcher, QtWatcher 27 | from qtsass.watchers.api import retry 28 | 29 | # Local imports 30 | from . import EXAMPLES_DIR, await_condition, example, touch 31 | 32 | 33 | class CallCounter(object): 34 | 35 | def __init__(self): 36 | self.count = 0 37 | 38 | def __call__(self, *args, **kwargs): 39 | self.count += 1 40 | 41 | 42 | @pytest.mark.parametrize( 43 | 'Watcher', (PollingWatcher, QtWatcher), 44 | ) 45 | @pytest.mark.flaky(max_runs=3) 46 | def test_watchers(Watcher, tmpdir): 47 | """Stress test Watcher implementations""" 48 | 49 | # Skip when QtWatcher is None - when Qt is not installed. 50 | if not Watcher: 51 | return 52 | 53 | watch_dir = tmpdir.join('src').strpath 54 | os.makedirs(watch_dir) 55 | shutil.copy2(example('dummy.scss'), watch_dir) 56 | input = tmpdir.join('src/dummy.scss').strpath 57 | output = tmpdir.join('build/dummy.css').strpath 58 | output_exists = lambda: exists(output) 59 | 60 | c = CallCounter() 61 | w = Watcher( 62 | watch_dir=watch_dir, 63 | compiler=compile_filename, 64 | args=(input, output), 65 | ) 66 | w.connect(c) 67 | 68 | # Output should not yet exist 69 | assert not exists(output) 70 | 71 | w.start() 72 | 73 | touch(input) 74 | time.sleep(0.5) 75 | if not await_condition(output_exists): 76 | assert False, 'Output file not created...' 77 | 78 | # Removing the watch_dir should not kill the Watcher 79 | # simply stop dispatching callbacks 80 | shutil.rmtree(watch_dir) 81 | time.sleep(0.5) 82 | assert c.count == 1 83 | 84 | # Watcher should recover once the input file is there again 85 | os.makedirs(watch_dir) 86 | shutil.copy2(example('dummy.scss'), watch_dir) 87 | time.sleep(0.5) 88 | assert c.count == 2 89 | 90 | # Stop watcher 91 | w.stop() 92 | w.join() 93 | 94 | for _ in range(5): 95 | touch(input) 96 | 97 | # Count should not change 98 | assert c.count == 2 99 | 100 | 101 | @pytest.mark.skipif(sys.platform.startswith('linux') or not QtWatcher, 102 | reason="Fails on linux") 103 | def test_qtwatcher(tmpdir): 104 | """Test QtWatcher implementation.""" 105 | # Constructing a QApplication will cause the QtWatcher constructed 106 | # below to use a Signal to dispatch callbacks. 107 | from qtsass.watchers.qt import QApplication 108 | 109 | qt_app = QApplication.instance() 110 | if not qt_app: 111 | qt_app = QApplication([]) 112 | 113 | watch_dir = tmpdir.join('src').strpath 114 | os.makedirs(watch_dir) 115 | shutil.copy2(example('dummy.scss'), watch_dir) 116 | input = tmpdir.join('src/dummy.scss').strpath 117 | output = tmpdir.join('build/dummy.css').strpath 118 | output_exists = lambda: exists(output) 119 | 120 | c = CallCounter() 121 | w = QtWatcher( 122 | watch_dir=watch_dir, 123 | compiler=compile_filename, 124 | args=(input, output), 125 | ) 126 | # We connect a counter directly to the Watcher's Qt Signal in order to 127 | # verify that the Watcher is actually using a Qt Signal. 128 | w.qtdispatcher.signal.connect(c) 129 | w.start() 130 | 131 | touch(input) 132 | time.sleep(0.5) 133 | if not await_condition(output_exists, qt_app=qt_app): 134 | assert False, 'Output file not created...' 135 | assert c.count == 1 136 | 137 | # Stop watcher 138 | w.stop() 139 | w.join() 140 | 141 | 142 | def test_retry(): 143 | """Test retry decorator""" 144 | 145 | @retry(5, interval=0) 146 | def succeeds_after(n, counter): 147 | counter() 148 | if n <= counter.count: 149 | return True 150 | raise ValueError 151 | 152 | # Succeed when attempts < retries 153 | assert succeeds_after(4, CallCounter()) 154 | 155 | # Fails when retries < attemps 156 | with pytest.raises(ValueError): 157 | assert succeeds_after(6, CallCounter()) 158 | 159 | @retry(5, interval=0) 160 | def fails(): 161 | raise ValueError 162 | 163 | # Most obvious case 164 | with pytest.raises(ValueError): 165 | fails() 166 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.orig 6 | *.pydevproject 7 | .project 8 | .metadata 9 | bin/ 10 | tmp/ 11 | *.tmp 12 | *.bak 13 | *.swp 14 | *~.nib 15 | local.properties 16 | .classpath 17 | .settings/ 18 | .loadpath 19 | 20 | # External tool builders 21 | .externalToolBuilders/ 22 | 23 | # Locally stored "Eclipse launch configurations" 24 | *.launch 25 | 26 | # CDT-specific 27 | .cproject 28 | 29 | # PDT-specific 30 | .buildpath 31 | 32 | 33 | ################# 34 | ## Visual Studio 35 | ################# 36 | 37 | ## Ignore Visual Studio temporary files, build results, and 38 | ## files generated by popular Visual Studio add-ons. 39 | 40 | # User-specific files 41 | *.suo 42 | *.user 43 | *.sln.docstates 44 | 45 | # Build results 46 | 47 | [Dd]ebug/ 48 | [Rr]elease/ 49 | x64/ 50 | build/ 51 | [Bb]in/ 52 | [Oo]bj/ 53 | 54 | # MSTest test Results 55 | [Tt]est[Rr]esult*/ 56 | [Bb]uild[Ll]og.* 57 | 58 | *_i.c 59 | *_p.c 60 | *.ilk 61 | *.meta 62 | *.obj 63 | *.pch 64 | *.pdb 65 | *.pgc 66 | *.pgd 67 | *.rsp 68 | *.sbr 69 | *.tlb 70 | *.tli 71 | *.tlh 72 | *.tmp 73 | *.tmp_proj 74 | *.log 75 | *.vspscc 76 | *.vssscc 77 | .builds 78 | *.pidb 79 | *.log 80 | *.scc 81 | 82 | # Visual C++ cache files 83 | ipch/ 84 | *.aps 85 | *.ncb 86 | *.opensdf 87 | *.sdf 88 | *.cachefile 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | 102 | # TeamCity is a build add-in 103 | _TeamCity* 104 | 105 | # DotCover is a Code Coverage Tool 106 | *.dotCover 107 | 108 | # NCrunch 109 | *.ncrunch* 110 | .*crunch*.local.xml 111 | 112 | # Installshield output folder 113 | [Ee]xpress/ 114 | 115 | # DocProject is a documentation generator add-in 116 | DocProject/buildhelp/ 117 | DocProject/Help/*.HxT 118 | DocProject/Help/*.HxC 119 | DocProject/Help/*.hhc 120 | DocProject/Help/*.hhk 121 | DocProject/Help/*.hhp 122 | DocProject/Help/Html2 123 | DocProject/Help/html 124 | 125 | # Click-Once directory 126 | publish/ 127 | 128 | # Publish Web Output 129 | *.Publish.xml 130 | *.pubxml 131 | *.publishproj 132 | 133 | # NuGet Packages Directory 134 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 135 | #packages/ 136 | 137 | # Windows Azure Build Output 138 | csx 139 | *.build.csdef 140 | 141 | # Windows Store app package directory 142 | AppPackages/ 143 | 144 | # Others 145 | sql/ 146 | *.Cache 147 | ClientBin/ 148 | [Ss]tyle[Cc]op.* 149 | ~$* 150 | *~ 151 | *.dbmdl 152 | *.[Pp]ublish.xml 153 | *.pfx 154 | *.publishsettings 155 | 156 | # RIA/Silverlight projects 157 | Generated_Code/ 158 | 159 | # Backup & report files from converting an old project file to a newer 160 | # Visual Studio version. Backup files are not needed, because we have git ;-) 161 | _UpgradeReport_Files/ 162 | Backup*/ 163 | UpgradeLog*.XML 164 | UpgradeLog*.htm 165 | 166 | # SQL Server files 167 | App_Data/*.mdf 168 | App_Data/*.ldf 169 | 170 | ############# 171 | ## Windows detritus 172 | ############# 173 | 174 | # Windows image file caches 175 | Thumbs.db 176 | ehthumbs.db 177 | 178 | # Folder config file 179 | Desktop.ini 180 | 181 | # Recycle Bin used on file shares 182 | $RECYCLE.BIN/ 183 | 184 | # Mac crap 185 | .DS_Store 186 | 187 | 188 | ############# 189 | ## Python 190 | ############# 191 | 192 | *.py[cod] 193 | 194 | # Packages 195 | *.egg 196 | *.egg-info 197 | dist/ 198 | build/ 199 | eggs/ 200 | parts/ 201 | var/ 202 | sdist/ 203 | develop-eggs/ 204 | .installed.cfg 205 | 206 | # Installer logs 207 | pip-log.txt 208 | 209 | # Unit test / coverage reports 210 | .coverage 211 | .tox 212 | 213 | #Translations 214 | *.mo 215 | 216 | #Mr Developer 217 | .mr.developer.cfg 218 | 219 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 220 | 221 | *.iml 222 | 223 | ## Directory-based project format: 224 | .idea/ 225 | # if you remove the above rule, at least ignore the following: 226 | 227 | # User-specific stuff: 228 | # .idea/workspace.xml 229 | # .idea/tasks.xml 230 | # .idea/dictionaries 231 | 232 | # Sensitive or high-churn files: 233 | # .idea/dataSources.ids 234 | # .idea/dataSources.xml 235 | # .idea/sqlDataSources.xml 236 | # .idea/dynamic.xml 237 | # .idea/uiDesigner.xml 238 | 239 | # Gradle: 240 | # .idea/gradle.xml 241 | # .idea/libraries 242 | 243 | # Mongo Explorer plugin: 244 | # .idea/mongoSettings.xml 245 | 246 | ## File-based project format: 247 | *.ipr 248 | *.iws 249 | 250 | ## Plugin-specific files: 251 | 252 | # IntelliJ 253 | /out/ 254 | 255 | # mpeltonen/sbt-idea plugin 256 | .idea_modules/ 257 | 258 | # JIRA plugin 259 | atlassian-ide-plugin.xml 260 | 261 | # Crashlytics plugin (for Android Studio and IntelliJ) 262 | com_crashlytics_export_strings.xml 263 | crashlytics.properties 264 | crashlytics-build.properties 265 | 266 | # pytest 267 | .pytest_cache 268 | 269 | # Project virtualenv 270 | venv/ 271 | 272 | # Rever 273 | rever/ 274 | activate.xsh 275 | 276 | # Loghub 277 | CHANGELOG.temp 278 | 279 | # Spyder 280 | .spyproject/ 281 | 282 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Test qtsass api.""" 10 | 11 | from __future__ import absolute_import 12 | 13 | # Standard library imports 14 | from os.path import exists 15 | import logging 16 | 17 | # Third party imports 18 | import pytest 19 | import sass 20 | 21 | # Local imports 22 | import qtsass 23 | 24 | # Local imports 25 | from . import EXAMPLES_DIR, PROJECT_DIR, example 26 | 27 | 28 | COLORS_STR = """ 29 | QWidget { 30 | background: rgba(127, 127, 127, 100%); 31 | color: rgb(255, 255, 255); 32 | } 33 | """ 34 | QLINEARGRADIENTS_STR = """ 35 | QWidget { 36 | background: qlineargradient( 37 | x1: 0, 38 | y1: 0, 39 | x2: 0, 40 | y2: 1, 41 | stop: 0.1 blue, 42 | stop: 0.8 green 43 | ); 44 | } 45 | """ 46 | QRADIANTGRADIENTS_STR = """ 47 | QWidget { 48 | background: qradialgradient( 49 | spread: repeat, 50 | cx: 0, 51 | cy: 0, 52 | fx: 0, 53 | fy: 1, 54 | stop: 0.1 blue, 55 | stop: 0.8 green 56 | ); 57 | } 58 | """ 59 | QNOT_STR = """ 60 | QLineEdit:!editable { 61 | background: white; 62 | } 63 | """ 64 | IMPORT_STR = """ 65 | @import 'dummy'; 66 | """ 67 | CUSTOM_BORDER_STR = """ 68 | QWidget { 69 | border: custom_border(); 70 | } 71 | """ 72 | 73 | 74 | def setup_module(): 75 | qtsass.enable_logging(level=logging.DEBUG) 76 | 77 | 78 | def teardown_module(): 79 | qtsass.enable_logging(level=logging.WARNING) 80 | 81 | 82 | def test_compile_strings(): 83 | """compile various strings.""" 84 | 85 | qtsass.compile(COLORS_STR) 86 | qtsass.compile(QLINEARGRADIENTS_STR) 87 | qtsass.compile(QRADIANTGRADIENTS_STR) 88 | qtsass.compile(QNOT_STR) 89 | 90 | 91 | def test_compile_import_raises(): 92 | """compile string with import raises.""" 93 | 94 | with pytest.raises(sass.CompileError): 95 | qtsass.compile(IMPORT_STR) 96 | 97 | 98 | def test_compile_import_with_include_paths(): 99 | """compile string with include_paths""" 100 | 101 | qtsass.compile(IMPORT_STR, include_paths=[EXAMPLES_DIR]) 102 | 103 | 104 | def test_compile_raises_ValueError(): 105 | """compile raises ValueError with invalid arguments""" 106 | 107 | # Pass invalid type to importers - must be sequence 108 | with pytest.raises(ValueError): 109 | qtsass.compile(COLORS_STR, importers=lambda x: None) 110 | 111 | # Pass invalid type to custom_functions 112 | with pytest.raises(ValueError): 113 | qtsass.compile(COLORS_STR, custom_functions=lambda x: None) 114 | 115 | 116 | def test_compile_custom_function(): 117 | """compile string with custom_functions""" 118 | 119 | custom_str = ( 120 | 'QWidget {\n' 121 | ' border: custom_border();\n' 122 | '}' 123 | ) 124 | 125 | def custom_border(): 126 | return '1px solid' 127 | 128 | css = qtsass.compile(custom_str, custom_functions=[custom_border]) 129 | assert '1px solid' in css 130 | assert 'custom_border()' not in css 131 | 132 | 133 | def test_compile_filename(tmpdir): 134 | """compile_filename simple.""" 135 | 136 | output = tmpdir.join('dummy.css') 137 | qtsass.compile_filename(example('dummy.scss'), output.strpath) 138 | assert exists(output.strpath) 139 | 140 | 141 | def test_compile_filename_no_save(): 142 | """compile_filename simple.""" 143 | 144 | qss = qtsass.compile_filename(example('dummy.scss')) 145 | assert isinstance(qss, str) 146 | 147 | 148 | def test_compile_filename_imports(tmpdir): 149 | """compile_filename with imports.""" 150 | 151 | output = tmpdir.join('dark.css') 152 | qtsass.compile_filename(example('complex', 'dark.scss'), output.strpath) 153 | assert exists(output.strpath) 154 | 155 | 156 | def test_compile_filename_imports_no_save(): 157 | """compile_filename with imports.""" 158 | 159 | qss = qtsass.compile_filename(example('complex', 'dark.scss')) 160 | assert isinstance(qss, str) 161 | 162 | 163 | def test_compile_dirname(tmpdir): 164 | """compile_dirname complex.""" 165 | 166 | output = tmpdir.join('complex') 167 | qtsass.compile_dirname(example('complex'), output.strpath) 168 | assert exists(output.join('dark.css').strpath) 169 | assert exists(output.join('light.css').strpath) 170 | 171 | 172 | def test_watch_raises_ValueError(tmpdir): 173 | """watch raises ValueError when source does not exist.""" 174 | 175 | # Watch file does not raise 176 | _ = qtsass.watch(example('dummy.scss'), tmpdir.join('dummy.scss').strpath) 177 | 178 | # Watch dir does not raise 179 | _ = qtsass.watch(example('complex'), tmpdir.join('complex').strpath) 180 | 181 | with pytest.raises(ValueError): 182 | _ = qtsass.watch('does_not_exist', 'does_not_exist') 183 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | # This avoids having duplicate builds for a pull request 5 | push: 6 | tags: 7 | - v** 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | smoke: 16 | name: Linux smoke test Py${{ matrix.PYTHON_VERSION }} 17 | runs-on: ubuntu-latest 18 | env: 19 | CI: True 20 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 21 | QT_DEBUG_PLUGINS: 1 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | PYTHON_VERSION: ['3.7', '3.10'] 26 | steps: 27 | - name: Checkout branch 28 | uses: actions/checkout@v4 29 | - name: Install Conda 30 | uses: conda-incubator/setup-miniconda@v3 31 | with: 32 | activate-environment: test 33 | python-version: ${{ matrix.PYTHON_VERSION }} 34 | channels: conda-forge 35 | - name: Install Dependencies 36 | shell: bash -l {0} 37 | run: pip install -r requirements/install.txt 38 | - name: Install Test Dependencies 39 | shell: bash -l {0} 40 | run: pip install -r requirements/dev.txt 41 | - name: Install Package 42 | shell: bash -l {0} 43 | run: python setup.py develop 44 | - name: Show Environment 45 | shell: bash -l {0} 46 | run: | 47 | conda info 48 | conda list 49 | - name: Format 50 | if: matrix.PYTHON_VERSION == '3.7' 51 | shell: bash -l {0} 52 | run: python run_checks_and_format.py 53 | - name: Run tests 54 | shell: bash -l {0} 55 | run: xvfb-run --auto-servernum pytest tests --cov=qtsass --cov-report=term-missing -x -vv 56 | - name: Upload coverage to Codecov 57 | shell: bash -l {0} 58 | run: codecov -t 74b56e56-6c81-4b43-b830-c46638da84a7 59 | 60 | linux: 61 | name: Linux Py${{ matrix.PYTHON_VERSION }} 62 | needs: smoke 63 | runs-on: ubuntu-latest 64 | env: 65 | CI: True 66 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 67 | QT_DEBUG_PLUGINS: 1 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | PYTHON_VERSION: ['3.8', '3.9'] 72 | steps: 73 | - name: Checkout branch 74 | uses: actions/checkout@v4 75 | - name: Install Conda 76 | uses: conda-incubator/setup-miniconda@v3 77 | with: 78 | activate-environment: test 79 | python-version: ${{ matrix.PYTHON_VERSION }} 80 | channels: conda-forge 81 | - name: Install Dependencies 82 | shell: bash -l {0} 83 | run: pip install -r requirements/install.txt 84 | - name: Install Test Dependencies 85 | shell: bash -l {0} 86 | run: pip install -r requirements/dev.txt 87 | - name: Install Package 88 | shell: bash -l {0} 89 | run: python setup.py develop 90 | - name: Show Environment 91 | shell: bash -l {0} 92 | run: | 93 | conda info 94 | conda list 95 | - name: Run tests 96 | shell: bash -l {0} 97 | run: xvfb-run --auto-servernum pytest tests --cov=qtsass --cov-report=term-missing -x -vv 98 | - name: Upload coverage to Codecov 99 | shell: bash -l {0} 100 | run: codecov -t 74b56e56-6c81-4b43-b830-c46638da84a7 101 | 102 | macos: 103 | name: Mac Py${{ matrix.PYTHON_VERSION }} 104 | needs: smoke 105 | runs-on: macos-latest 106 | env: 107 | CI: True 108 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 109 | QT_DEBUG_PLUGINS: 1 110 | strategy: 111 | fail-fast: false 112 | matrix: 113 | PYTHON_VERSION: ['3.8', '3.9', '3.10'] 114 | steps: 115 | - name: Checkout branch 116 | uses: actions/checkout@v4 117 | - name: Install Conda 118 | uses: conda-incubator/setup-miniconda@v3 119 | with: 120 | activate-environment: test 121 | python-version: ${{ matrix.PYTHON_VERSION }} 122 | channels: conda-forge 123 | - name: Install Dependencies 124 | shell: bash -l {0} 125 | run: pip install -r requirements/install.txt 126 | - name: Install Test Dependencies 127 | shell: bash -l {0} 128 | run: pip install -r requirements/dev.txt 129 | - name: Install Package 130 | shell: bash -l {0} 131 | run: python setup.py develop 132 | - name: Show Environment 133 | shell: bash -l {0} 134 | run: | 135 | conda info 136 | conda list 137 | - name: Run tests 138 | shell: bash -l {0} 139 | run: pytest tests --cov=qtsass --cov-report=term-missing -x -vv 140 | - name: Upload coverage to Codecov 141 | shell: bash -l {0} 142 | run: codecov -t 74b56e56-6c81-4b43-b830-c46638da84a7 143 | 144 | windows: 145 | name: Windows Py${{ matrix.PYTHON_VERSION }} 146 | needs: smoke 147 | runs-on: windows-latest 148 | env: 149 | CI: True 150 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 151 | QT_DEBUG_PLUGINS: 1 152 | strategy: 153 | fail-fast: false 154 | matrix: 155 | PYTHON_VERSION: ['3.7', '3.8', '3.9', '3.10'] 156 | steps: 157 | - name: Checkout branch 158 | uses: actions/checkout@v4 159 | - name: Install Conda 160 | uses: conda-incubator/setup-miniconda@v3 161 | with: 162 | activate-environment: test 163 | python-version: ${{ matrix.PYTHON_VERSION }} 164 | channels: conda-forge 165 | - name: Install Dependencies 166 | shell: bash -l {0} 167 | run: pip install -r requirements/install.txt 168 | - name: Install Test Dependencies 169 | shell: bash -l {0} 170 | run: pip install -r requirements/dev.txt 171 | - name: Install Package 172 | shell: bash -l {0} 173 | run: python setup.py develop 174 | - name: Show Environment 175 | shell: bash -l {0} 176 | run: | 177 | conda info 178 | conda list 179 | - name: Run tests 180 | shell: bash -l {0} 181 | run: pytest tests --cov=qtsass --cov-report=term-missing -x -vv 182 | - name: Upload coverage to Codecov 183 | shell: bash -l {0} 184 | run: codecov -t 74b56e56-6c81-4b43-b830-c46638da84a7 185 | 186 | deploy: 187 | runs-on: ubuntu-latest 188 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 189 | needs: [windows, macos, linux] 190 | steps: 191 | - uses: actions/checkout@v4 192 | - name: Set up Python 3.10 193 | uses: actions/setup-python@v5 194 | with: 195 | python-version: 3.10 196 | - name: Install dependencies 197 | run: | 198 | python -m pip install --upgrade pip wheel setuptools 199 | - name: Build dist 200 | run: | 201 | python setup.py sdist bdist_wheel 202 | 203 | - name: Publish package 204 | uses: pypa/gh-action-pypi-publish@release/v1 205 | with: 206 | user: __token__ 207 | password: ${{ secrets.pypi_password }} 208 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Test qtsass cli.""" 10 | 11 | from __future__ import absolute_import 12 | 13 | # Standard library imports 14 | from collections import namedtuple 15 | from os.path import basename, exists 16 | from subprocess import PIPE, Popen 17 | import sys 18 | import time 19 | 20 | # Local imports 21 | from . import PROJECT_DIR, await_condition, example, touch 22 | 23 | 24 | SLEEP_INTERVAL = 1 25 | Result = namedtuple('Result', "code stdout stderr") 26 | 27 | 28 | def indent(text, prefix=' '): 29 | """Like textwrap.indent""" 30 | 31 | return ''.join([prefix + line for line in text.splitlines(True)]) 32 | 33 | 34 | def invoke(args): 35 | """Invoke qtsass cli with specified args""" 36 | 37 | kwargs = dict( 38 | stdout=PIPE, 39 | stderr=PIPE, 40 | cwd=PROJECT_DIR 41 | ) 42 | proc = Popen([sys.executable, '-m', 'qtsass'] + args, **kwargs) 43 | return proc 44 | 45 | 46 | def invoke_with_result(args): 47 | """Invoke qtsass cli and return a Result obj""" 48 | 49 | proc = invoke(args) 50 | out, err = proc.communicate() 51 | out = out.decode('ascii', errors="ignore") 52 | err = err.decode('ascii', errors="ignore") 53 | return Result(proc.returncode, out, err) 54 | 55 | 56 | def kill(proc, timeout=1): 57 | """Kill a subprocess and return a Result obj""" 58 | 59 | proc.kill() 60 | out, err = proc.communicate() 61 | out = out.decode('ascii', errors="ignore") 62 | err = err.decode('ascii', errors="ignore") 63 | return Result(proc.returncode, out, err) 64 | 65 | 66 | def format_result(result): 67 | """Format a subprocess Result obj""" 68 | 69 | out = [ 70 | 'Subprocess Report...', 71 | 'Exit code: %s' % result.code, 72 | ] 73 | if result.stdout: 74 | out.append('stdout:') 75 | out.append(indent(result.stdout, ' ')) 76 | if result.stderr: 77 | out.append('stderr:') 78 | out.append(indent(result.stderr, ' ')) 79 | return '\n'.join(out) 80 | 81 | 82 | def test_compile_dummy_to_stdout(): 83 | """CLI compile dummy example to stdout.""" 84 | 85 | args = [example('dummy.scss')] 86 | result = invoke_with_result(args) 87 | 88 | assert result.code == 0 89 | assert result.stdout 90 | 91 | 92 | def test_compile_dummy_to_file(tmpdir): 93 | """CLI compile dummy example to file.""" 94 | 95 | input = example('dummy.scss') 96 | output = tmpdir.join('dummy.css') 97 | args = [input, '-o', output.strpath] 98 | result = invoke_with_result(args) 99 | 100 | assert result.code == 0 101 | assert exists(output.strpath) 102 | 103 | 104 | def test_watch_dummy(tmpdir): 105 | """CLI watch dummy example.""" 106 | 107 | input = example('dummy.scss') 108 | output = tmpdir.join('dummy.css') 109 | args = [input, '-o', output.strpath, '-w'] 110 | proc = invoke(args) 111 | 112 | # Wait for initial compile 113 | output_exists = lambda: exists(output.strpath) 114 | if not await_condition(output_exists): 115 | result = kill(proc) 116 | report = format_result(result) 117 | err = "Failed to compile dummy.scss\n" 118 | err += report 119 | assert False, report 120 | 121 | # Ensure subprocess is still alive 122 | assert proc.poll() is None 123 | 124 | # Touch input file, triggering a recompile 125 | created = output.mtime() 126 | file_modified = lambda: output.mtime() > created 127 | time.sleep(SLEEP_INTERVAL) 128 | touch(input) 129 | 130 | if not await_condition(file_modified): 131 | result = kill(proc) 132 | report = format_result(result) 133 | err = 'Modifying %s did not trigger recompile.\n' % basename(input) 134 | err += report 135 | assert False, err 136 | 137 | kill(proc) 138 | 139 | 140 | def test_compile_complex(tmpdir): 141 | """CLI compile complex example.""" 142 | 143 | input = example('complex') 144 | output = tmpdir.mkdir('output') 145 | args = [input, '-o', output.strpath] 146 | result = invoke_with_result(args) 147 | 148 | assert result.code == 0 149 | 150 | expected_files = [output.join('light.css'), output.join('dark.css')] 151 | for file in expected_files: 152 | assert exists(file.strpath) 153 | 154 | 155 | def test_watch_complex(tmpdir): 156 | """CLI watch complex example.""" 157 | 158 | input = example('complex') 159 | output = tmpdir.mkdir('output') 160 | args = [input, '-o', output.strpath, '-w'] 161 | proc = invoke(args) 162 | 163 | expected_files = [output.join('light.css'), output.join('dark.css')] 164 | 165 | # Wait for initial compile 166 | files_created = lambda: all([exists(f.strpath) for f in expected_files]) 167 | if not await_condition(files_created): 168 | result = kill(proc) 169 | report = format_result(result) 170 | err = 'All expected files have not been created...' 171 | err += report 172 | assert False, err 173 | 174 | # Ensure subprocess is still alive 175 | assert proc.poll() is None 176 | 177 | # Input files to touch 178 | input_full = example('complex', 'light.scss') 179 | input_partial = example('complex', '_base.scss') 180 | input_nested = example('complex', 'widgets', '_qwidget.scss') 181 | 182 | def touch_and_wait(input_file, timeout=2000): 183 | """Touch a file, triggering a recompile""" 184 | 185 | filename = basename(input_file) 186 | old_mtimes = [f.mtime() for f in expected_files] 187 | files_modified = lambda: all( 188 | [f.mtime() > old_mtimes[i] for i, f in enumerate(expected_files)] 189 | ) 190 | time.sleep(SLEEP_INTERVAL) 191 | touch(input_file) 192 | 193 | if not await_condition(files_modified, timeout): 194 | result = kill(proc) 195 | report = format_result(result) 196 | err = 'Modifying %s did not trigger recompile.\n' % filename 197 | err += report 198 | for i, f in enumerate(expected_files): 199 | err += str(f) + '\n' 200 | err += str(old_mtimes[i]) + '\n' 201 | err += str(f.mtime()) + '\n' 202 | err += str(bool(f.mtime() > old_mtimes[i])) + '\n' 203 | assert False, err 204 | 205 | return True 206 | 207 | assert touch_and_wait(input_full) 208 | assert touch_and_wait(input_partial) 209 | assert touch_and_wait(input_nested) 210 | 211 | kill(proc) 212 | 213 | 214 | def test_invalid_input(): 215 | """CLI input is not a file or dir.""" 216 | 217 | proc = invoke_with_result(['file_does_not_exist.scss']) 218 | assert proc.code == 1 219 | assert 'Error: input must be' in proc.stdout 220 | 221 | proc = invoke_with_result(['./dir/does/not/exist']) 222 | assert proc.code == 1 223 | assert 'Error: input must be' in proc.stdout 224 | 225 | 226 | def test_dir_missing_output(): 227 | """CLI dir missing output option""" 228 | 229 | proc = invoke_with_result([example('complex')]) 230 | assert proc.code == 1 231 | assert 'Error: missing required option' in proc.stdout 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QtSASS: Compile SCSS files to Qt stylesheets 2 | 3 | [![License - MIT](https://img.shields.io/github/license/spyder-ide/qtsass.svg)](./LICENSE.txt) 4 | [![OpenCollective Backers](https://opencollective.com/spyder/backers/badge.svg?color=blue)](#backers) 5 | [![Join the chat at https://gitter.im/spyder-ide/public](https://badges.gitter.im/spyder-ide/spyder.svg)](https://gitter.im/spyder-ide/public)
6 | [![Github build status](https://github.com/spyder-ide/qtsass/workflows/Tests/badge.svg)](https://github.com/spyder-ide/qtsass/actions) 7 | [![Codecov coverage](https://img.shields.io/codecov/c/github/spyder-ide/qtsass/master.svg)](https://codecov.io/gh/spyder-ide/qtsass) 8 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/spyder-ide/qtsass/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/spyder-ide/qtsass/?branch=master) 9 | 10 | *Copyright © 2015 Yann Lanthony* 11 | 12 | *Copyright © 2017–2018 Spyder Project Contributors* 13 | 14 | 15 | ## Overview 16 | 17 | [SASS](http://sass-lang.com/) brings countless amazing features to CSS. 18 | Besides being used in web development, CSS is also the way to stylize Qt-based desktop applications. 19 | However, Qt's CSS has a few variations that prevent the direct use of SASS compiler. 20 | 21 | The purpose of this tool is to fill the gap between SASS and Qt-CSS by handling those variations. 22 | 23 | 24 | ## Qt's CSS specificities 25 | 26 | The goal of QtSASS is to be able to generate a Qt-CSS stylesheet based on a 100% valid SASS file. 27 | This is how it deals with Qt's specifics and how you should modify your CSS stylesheet to use QtSASS. 28 | 29 | #### "!" in selectors 30 | Qt allows to define the style of a widget according to its states, like this: 31 | 32 | ```css 33 | QLineEdit:enabled { 34 | ... 35 | } 36 | ``` 37 | 38 | However, a "not" state is problematic because it introduces an exclamation mark in the selector's name, which is not valid SASS/CSS: 39 | 40 | ```css 41 | QLineEdit:!editable { 42 | ... 43 | } 44 | ``` 45 | 46 | QtSASS allows "!" in selectors' names; the SASS file is preprocessed and any occurence of `:!` is replaced by `:_qnot_` (for "Qt not"). 47 | However, using this feature prevents from having a 100% valid SASS file, so this support of `!` might change in the future. 48 | This can be replaced by the direct use of the `_qnot_` keyword in your SASS file: 49 | 50 | ```css 51 | QLineEdit:_qnot_editable { /* will generate QLineEdit:!editable { */ 52 | ... 53 | } 54 | ``` 55 | 56 | #### qlineargradient 57 | The qlineargradient function also has a non-valid CSS syntax. 58 | 59 | ```css 60 | qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0.1 blue, stop: 0.8 green) 61 | ``` 62 | 63 | To support qlineargradient QtSASS provides a preprocessor and a SASS implementation of the qlineargradient function. The above QSS syntax will be replaced with the following: 64 | 65 | ```css 66 | qlineargradient(0, 0, 0, 1, (0.1 blue, 0.8 green)) 67 | ``` 68 | 69 | You may also use this syntax directly in your QtSASS. 70 | 71 | ``` 72 | qlineargradient(0, 0, 0, 1, (0.1 blue, 0.8 green)) 73 | # the stops parameter is a list, so you can also use variables: 74 | $stops = 0.1 blue, 0.8 green 75 | qlineargradient(0, 0, 0, 0, $stops) 76 | ``` 77 | 78 | #### qrgba 79 | Qt's rgba: 80 | 81 | ```css 82 | rgba(255, 128, 128, 50%) 83 | ``` 84 | 85 | is replaced by CSS rgba: 86 | 87 | ```css 88 | rgba(255, 128, 128, 0.5) 89 | ``` 90 | 91 | 92 | ## Executable usage 93 | 94 | To compile your SASS stylesheet to a Qt compliant CSS file: 95 | 96 | ```bash 97 | # If -o is omitted, output will be printed to console 98 | qtsass style.scss -o style.css 99 | ``` 100 | 101 | To use the watch mode and get your stylesheet auto recompiled on each file save: 102 | 103 | ```bash 104 | # If -o is omitted, output will be print to console 105 | qtsass style.scss -o style.css -w 106 | ``` 107 | 108 | To compile a directory containing SASS stylesheets to Qt compliant CSS files: 109 | 110 | ```bash 111 | qtsass ./static/scss -o ./static/css 112 | ``` 113 | 114 | You can also use watch mode to watch the entire directory for changes. 115 | 116 | ```bash 117 | qtsass ./static/scss -o ./static/css -w 118 | ``` 119 | 120 | Set the Environment Variable QTSASS_DEBUG to 1 or pass the --debug flag to enable logging. 121 | 122 | ```bash 123 | qtsass ./static/scss -o ./static/css --debug 124 | ``` 125 | 126 | ## API methods 127 | 128 | ### `compile(string, **kwargs)` 129 | 130 | Conform and Compile QtSASS source code to CSS. 131 | 132 | This function conforms QtSASS to valid SCSS before passing it to 133 | sass.compile. Any keyword arguments you provide will be combined with 134 | qtsass's default keyword arguments and passed to sass.compile. 135 | 136 | Examples: 137 | 138 | ```bash 139 | >>> import qtsass 140 | >>> qtsass.compile("QWidget {background: rgb(0, 0, 0);}") 141 | QWidget {background:black;} 142 | ``` 143 | 144 | Arguments: 145 | - string: QtSASS source code to conform and compile. 146 | - kwargs: Keyword arguments to pass to sass.compile 147 | 148 | Returns: 149 | - Qt compliant CSS string 150 | 151 | ### `compile_filename(input_file, output_file=None, **kwargs)`: 152 | 153 | Compile and return a QtSASS file as Qt compliant CSS. Optionally save to a file. 154 | 155 | Examples: 156 | 157 | ```bash 158 | >>> import qtsass 159 | >>> qtsass.compile_filename("dummy.scss", "dummy.css") 160 | >>> css = qtsass.compile_filename("dummy.scss") 161 | ``` 162 | 163 | Arguments: 164 | - input_file: Path to QtSass file. 165 | - output_file: Path to write Qt compliant CSS. 166 | - kwargs: Keyword arguments to pass to sass.compile 167 | 168 | Returns: 169 | - Qt compliant CSS string 170 | 171 | ### `compile_dirname(input_dir, output_dir, **kwargs)`: 172 | 173 | Compiles QtSASS files in a directory including subdirectories. 174 | 175 | ```bash 176 | >>> import qtsass 177 | >>> qtsass.compile_dirname("./scss", "./css") 178 | ``` 179 | 180 | Arguments: 181 | - input_dir: Path to directory containing QtSass files. 182 | - output_dir: Directory to write compiled Qt compliant CSS files to. 183 | - kwargs: Keyword arguments to pass to sass.compile 184 | 185 | ### `enable_logging(level=None, handler=None)`: 186 | Enable logging for qtsass. 187 | 188 | Sets the qtsass logger's level to: 189 | 1. the provided logging level 190 | 2. logging.DEBUG if the QTSASS_DEBUG envvar is a True value 191 | 3. logging.WARNING 192 | 193 | ```bash 194 | >>> import logging 195 | >>> import qtsass 196 | >>> handler = logging.StreamHandler() 197 | >>> formatter = logging.Formatter('%(level)-8s: %(name)s> %(message)s') 198 | >>> handler.setFormatter(formatter) 199 | >>> qtsass.enable_logging(level=logging.DEBUG, handler=handler) 200 | ``` 201 | 202 | Arguments: 203 | - level: Optional logging level 204 | - handler: Optional handler to add 205 | 206 | ### `watch(source, destination, compiler=None, Watcher=None)`: 207 | Watches a source file or directory, compiling QtSass files when modified. 208 | 209 | The compiler function defaults to compile_filename when source is a file 210 | and compile_dirname when source is a directory. 211 | 212 | Arguments: 213 | - source: Path to source QtSass file or directory. 214 | - destination: Path to output css file or directory. 215 | - compiler: Compile function (optional) 216 | - Watcher: Defaults to qtsass.watchers.Watcher (optional) 217 | 218 | Returns: 219 | - qtsass.watchers.Watcher instance 220 | 221 | ## Contributing 222 | 223 | Everyone is welcome to contribute! 224 | 225 | 226 | ## Sponsors 227 | 228 | Spyder and its subprojects are funded thanks to the generous support of 229 | 230 | [![Quansight](https://static.wixstatic.com/media/095d2c_2508c560e87d436ea00357abc404cf1d~mv2.png/v1/crop/x_0,y_9,w_915,h_329/fill/w_380,h_128,al_c,usm_0.66_1.00_0.01/095d2c_2508c560e87d436ea00357abc404cf1d~mv2.png)](https://www.quansight.com/)[![Numfocus](https://i2.wp.com/numfocus.org/wp-content/uploads/2017/07/NumFocus_LRG.png?fit=320%2C148&ssl=1)](https://numfocus.org/) 231 | 232 | 233 | and the donations we have received from our users around the world through [Open Collective](https://opencollective.com/spyder/): 234 | 235 | [![Sponsors](https://opencollective.com/spyder/sponsors.svg)](https://opencollective.com/spyder#support) 236 | 237 | Please consider becoming a sponsor! 238 | -------------------------------------------------------------------------------- /qtsass/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """qtsass - Compile SCSS files to valid Qt stylesheets.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | # Standard library imports 16 | from collections.abc import Mapping, Sequence 17 | import logging 18 | import os 19 | 20 | # Third party imports 21 | import sass 22 | 23 | # Local imports 24 | from qtsass.conformers import qt_conform, scss_conform 25 | from qtsass.functions import qlineargradient, qradialgradient, rgba 26 | from qtsass.importers import qss_importer 27 | 28 | 29 | # yapf: enable 30 | 31 | # Constants 32 | DEFAULT_CUSTOM_FUNCTIONS = { 33 | 'qlineargradient': qlineargradient, 34 | 'qradialgradient': qradialgradient, 35 | 'rgba': rgba 36 | } 37 | DEFAULT_SOURCE_COMMENTS = False 38 | 39 | # Logger setup 40 | _log = logging.getLogger(__name__) 41 | 42 | 43 | def compile(string, **kwargs): 44 | """ 45 | Conform and Compile QtSASS source code to CSS. 46 | 47 | This function conforms QtSASS to valid SCSS before passing it to 48 | sass.compile. Any keyword arguments you provide will be combined with 49 | qtsass's default keyword arguments and passed to sass.compile. 50 | 51 | .. code-block:: python 52 | 53 | >>> import qtsass 54 | >>> qtsass.compile("QWidget {background: rgb(0, 0, 0);}") 55 | QWidget {background:black;} 56 | 57 | :param string: QtSASS source code to conform and compile. 58 | :param kwargs: Keyword arguments to pass to sass.compile 59 | :returns: CSS string 60 | """ 61 | kwargs.setdefault('source_comments', DEFAULT_SOURCE_COMMENTS) 62 | kwargs.setdefault('custom_functions', []) 63 | kwargs.setdefault('importers', []) 64 | kwargs.setdefault('include_paths', []) 65 | 66 | # Add QtSass importers 67 | if isinstance(kwargs['importers'], Sequence): 68 | kwargs['importers'] = (list(kwargs['importers']) + 69 | [(0, qss_importer(*kwargs['include_paths']))]) 70 | else: 71 | raise ValueError('Expected Sequence for importers ' 72 | 'got {}'.format(type(kwargs['importers']))) 73 | 74 | # Add QtSass custom_functions 75 | if isinstance(kwargs['custom_functions'], Sequence): 76 | kwargs['custom_functions'] = dict( 77 | DEFAULT_CUSTOM_FUNCTIONS, 78 | **{fn.__name__: fn 79 | for fn in kwargs['custom_functions']}) 80 | elif isinstance(kwargs['custom_functions'], Mapping): 81 | kwargs['custom_functions'].update(DEFAULT_CUSTOM_FUNCTIONS) 82 | else: 83 | raise ValueError('Expected Sequence or Mapping for custom_functions ' 84 | 'got {}'.format(type(kwargs['custom_functions']))) 85 | 86 | # Conform QtSass source code 87 | try: 88 | kwargs['string'] = scss_conform(string) 89 | except Exception: 90 | _log.error('Failed to conform source code') 91 | raise 92 | 93 | if _log.isEnabledFor(logging.DEBUG): 94 | from pprint import pformat 95 | log_kwargs = dict(kwargs) 96 | log_kwargs['string'] = 'Conformed SCSS<...>' 97 | _log.debug('Calling sass.compile with:') 98 | _log.debug(pformat(log_kwargs)) 99 | _log.debug('Conformed scss:\n{}'.format(kwargs['string'])) 100 | 101 | # Compile QtSass source code 102 | try: 103 | return qt_conform(sass.compile(**kwargs)) 104 | except sass.CompileError: 105 | _log.error('Failed to compile source code') 106 | raise 107 | 108 | 109 | def compile_filename(input_file, output_file=None, **kwargs): 110 | """Compile and return a QtSASS file as Qt compliant CSS. 111 | Optionally save to a file. 112 | 113 | .. code-block:: python 114 | 115 | >>> import qtsass 116 | >>> qtsass.compile_filename("dummy.scss", "dummy.css") 117 | >>> css = qtsass.compile_filename("dummy.scss") 118 | 119 | :param input_file: Path to QtSass file. 120 | :param output_file: Optional path to write Qt compliant CSS. 121 | :param kwargs: Keyword arguments to pass to sass.compile 122 | :returns: CSS string 123 | """ 124 | input_root = os.path.abspath(os.path.dirname(input_file)) 125 | kwargs.setdefault('include_paths', [input_root]) 126 | 127 | with open(input_file, 'r') as f: 128 | string = f.read() 129 | 130 | _log.info('Compiling {}...'.format(os.path.normpath(input_file))) 131 | css = compile(string, **kwargs) 132 | 133 | if output_file is not None: 134 | output_root = os.path.abspath(os.path.dirname(output_file)) 135 | if not os.path.isdir(output_root): 136 | os.makedirs(output_root) 137 | 138 | with open(output_file, 'w') as css_file: 139 | css_file.write(css) 140 | _log.info('Created CSS file {}'.format( 141 | os.path.normpath(output_file))) 142 | 143 | return css 144 | 145 | 146 | def compile_dirname(input_dir, output_dir, **kwargs): 147 | """Compiles QtSASS files in a directory including subdirectories. 148 | 149 | .. code-block:: python 150 | 151 | >>> import qtsass 152 | >>> qtsass.compile_dirname("./scss", "./css") 153 | 154 | :param input_dir: Directory containing QtSass files. 155 | :param output_dir: Directory to write compiled Qt compliant CSS files to. 156 | :param kwargs: Keyword arguments to pass to sass.compile 157 | """ 158 | kwargs.setdefault('include_paths', [input_dir]) 159 | 160 | def is_valid(file_name): 161 | return not file_name.startswith('_') and file_name.endswith('.scss') 162 | 163 | for root, _, files in os.walk(input_dir): 164 | relative_root = os.path.relpath(root, input_dir) 165 | output_root = os.path.join(output_dir, relative_root) 166 | fkwargs = dict(kwargs) 167 | fkwargs['include_paths'] = fkwargs['include_paths'] + [root] 168 | 169 | for file_name in [f for f in files if is_valid(f)]: 170 | scss_path = os.path.join(root, file_name) 171 | css_file = os.path.splitext(file_name)[0] + '.css' 172 | css_path = os.path.join(output_root, css_file) 173 | 174 | if not os.path.isdir(output_root): 175 | os.makedirs(output_root) 176 | 177 | compile_filename(scss_path, css_path, **fkwargs) 178 | 179 | 180 | def enable_logging(level=None, handler=None): 181 | """Enable logging for qtsass. 182 | 183 | Sets the qtsass logger's level to: 184 | 1. the provided logging level 185 | 2. logging.DEBUG if the QTSASS_DEBUG envvar is a True value 186 | 3. logging.WARNING 187 | 188 | .. code-block:: python 189 | >>> import logging 190 | >>> import qtsass 191 | >>> handler = logging.StreamHandler() 192 | >>> formatter = logging.Formatter('%(level)-8s: %(name)s> %(message)s') 193 | >>> handler.setFormatter(formatter) 194 | >>> qtsass.enable_logging(level=logging.DEBUG, handler=handler) 195 | 196 | :param level: Optional logging level 197 | :param handler: Optional handler to add 198 | """ 199 | if level is None: 200 | debug = os.environ.get('QTSASS_DEBUG', False) 201 | if debug in ('1', 'true', 'True', 'TRUE', 'on', 'On', 'ON'): 202 | level = logging.DEBUG 203 | else: 204 | level = logging.WARNING 205 | 206 | logger = logging.getLogger('qtsass') 207 | logger.setLevel(level) 208 | if handler: 209 | logger.addHandler(handler) 210 | _log.debug('logging level set to {}.'.format(level)) 211 | 212 | 213 | def watch(source, destination, compiler=None, Watcher=None): 214 | """ 215 | Watches a source file or directory, compiling QtSass files when modified. 216 | 217 | The compiler function defaults to compile_filename when source is a file 218 | and compile_dirname when source is a directory. 219 | 220 | :param source: Path to source QtSass file or directory. 221 | :param destination: Path to output css file or directory. 222 | :param compiler: Compile function (optional) 223 | :param Watcher: Defaults to qtsass.watchers.Watcher (optional) 224 | :returns: qtsass.watchers.Watcher instance 225 | """ 226 | if os.path.isfile(source): 227 | watch_dir = os.path.dirname(source) 228 | compiler = compiler or compile_filename 229 | elif os.path.isdir(source): 230 | watch_dir = source 231 | compiler = compiler or compile_dirname 232 | else: 233 | raise ValueError('source arg must be a dirname or filename...') 234 | 235 | if Watcher is None: 236 | from qtsass.watchers import Watcher 237 | 238 | watcher = Watcher(watch_dir, compiler, (source, destination)) 239 | return watcher 240 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # History of changes 2 | 3 | ## Version 0.4.0 (2023/03/27) 4 | 5 | ### Issues Closed 6 | 7 | * [Issue 73](https://github.com/spyder-ide/qtsass/issues/73) - Release QtSASS 0.4.0 8 | * [Issue 67](https://github.com/spyder-ide/qtsass/issues/67) - Drop support for Python 2.7, 3.5, and 3.6 ([PR 71](https://github.com/spyder-ide/qtsass/pull/71) by [@dalthviz](https://github.com/dalthviz)) 9 | 10 | In this release 2 issues were closed. 11 | 12 | ### Pull Requests Merged 13 | 14 | * [PR 71](https://github.com/spyder-ide/qtsass/pull/71) - PR: Drop support for Python <=3.6, by [@dalthviz](https://github.com/dalthviz) ([67](https://github.com/spyder-ide/qtsass/issues/67)) 15 | * [PR 70](https://github.com/spyder-ide/qtsass/pull/70) - PR: Made `compile_filename` file output optional, by [@gentlegiantJGC](https://github.com/gentlegiantJGC) 16 | * [PR 69](https://github.com/spyder-ide/qtsass/pull/69) - PR: Update RELEASE.md, by [@dalthviz](https://github.com/dalthviz) 17 | * [PR 59](https://github.com/spyder-ide/qtsass/pull/59) - PR: Add the support for QRadialGradient, by [@regrainb](https://github.com/regrainb) ([57](https://github.com/spyder-ide/qtsass/issues/57)) 18 | 19 | In this release 4 pull requests were closed. 20 | 21 | ## Version 0.3.2 (2022/09/16) 22 | 23 | ### Pull Requests Merged 24 | 25 | * [PR 68](https://github.com/spyder-ide/qtsass/pull/68) - PR: Constraint libsass to 0.21.0 and update .authors.yml file, by [@dalthviz](https://github.com/dalthviz) 26 | * [PR 66](https://github.com/spyder-ide/qtsass/pull/66) - PR: Update changelog and authors, by [@dalthviz](https://github.com/dalthviz) 27 | * [PR 65](https://github.com/spyder-ide/qtsass/pull/65) - PR:🚇 Add deployment pipeline step, by [@s-weigand](https://github.com/s-weigand) 28 | 29 | In this release 3 pull requests were closed. 30 | 31 | ## Version 0.3.1 (2022/09/05) 32 | 33 | ### Issues Closed 34 | 35 | * [Issue 60](https://github.com/spyder-ide/qtsass/issues/60) - Release QtSASS 0.3.1 ([PR 64](https://github.com/spyder-ide/qtsass/pull/64) by [@dalthviz](https://github.com/dalthviz)) 36 | * [Issue 52](https://github.com/spyder-ide/qtsass/issues/52) - Update 'collections' imports for Python 3.3+ ([PR 54](https://github.com/spyder-ide/qtsass/pull/54) by [@goanpeca](https://github.com/goanpeca)) 37 | 38 | In this release 2 issues were closed. 39 | 40 | ### Pull Requests Merged 41 | 42 | * [PR 64](https://github.com/spyder-ide/qtsass/pull/64) - PR: Update CI Python versions and classifiers, by [@dalthviz](https://github.com/dalthviz) ([60](https://github.com/spyder-ide/qtsass/issues/60)) 43 | * [PR 54](https://github.com/spyder-ide/qtsass/pull/54) - PR: Add check for deprecated api between 2 and 3 versions, by [@goanpeca](https://github.com/goanpeca) ([52](https://github.com/spyder-ide/qtsass/issues/52)) 44 | 45 | In this release 2 pull requests were closed. 46 | 47 | ## Version qtsass v0.3.0 (2020/03/18) 48 | 49 | ### Issues Closed 50 | 51 | * [Issue 50](https://github.com/spyder-ide/qtsass/issues/50) - Add rever to release process ([PR 51](https://github.com/spyder-ide/qtsass/pull/51) by [@goanpeca](https://github.com/goanpeca)) 52 | * [Issue 48](https://github.com/spyder-ide/qtsass/issues/48) - Move CI to github actions ([PR 49](https://github.com/spyder-ide/qtsass/pull/49) by [@goanpeca](https://github.com/goanpeca)) 53 | 54 | In this release 2 issues were closed. 55 | 56 | ### Pull Requests Merged 57 | 58 | * [PR 51](https://github.com/spyder-ide/qtsass/pull/51) - PR: Add rever to release process, by [@goanpeca](https://github.com/goanpeca) ([50](https://github.com/spyder-ide/qtsass/issues/50)) 59 | * [PR 49](https://github.com/spyder-ide/qtsass/pull/49) - PR: Move to github actions, by [@goanpeca](https://github.com/goanpeca) ([48](https://github.com/spyder-ide/qtsass/issues/48)) 60 | 61 | In this release 2 pull requests were closed. 62 | 63 | ## Version 0.2 (2020-02-23) 64 | 65 | ### Issues Closed 66 | 67 | * [Issue 43](https://github.com/spyder-ide/qtsass/issues/43) - rgba function breaks on incoming 8bit ints ([PR 40](https://github.com/spyder-ide/qtsass/pull/40) by [@ewerybody](https://github.com/ewerybody)) 68 | * [Issue 42](https://github.com/spyder-ide/qtsass/issues/42) - qlineargradient x1,y1,x2,y2 values can be floats! ([PR 40](https://github.com/spyder-ide/qtsass/pull/40) by [@ewerybody](https://github.com/ewerybody)) 69 | * [Issue 41](https://github.com/spyder-ide/qtsass/issues/41) - make qtsass watchdog dependence optional 70 | 71 | In this release 3 issues were closed. 72 | 73 | ### Pull Requests Merged 74 | 75 | * [PR 44](https://github.com/spyder-ide/qtsass/pull/44) - Add Watcher api, by [@danbradham](https://github.com/danbradham) 76 | * [PR 40](https://github.com/spyder-ide/qtsass/pull/40) - added support for incomplete coords and incoming %-rgba values ..., by [@ewerybody](https://github.com/ewerybody) ([43](https://github.com/spyder-ide/qtsass/issues/43), [42](https://github.com/spyder-ide/qtsass/issues/42)) 77 | 78 | In this release 2 pull requests were closed. 79 | 80 | ## Version 0.1.0 (2019-05-05) 81 | 82 | ### Issues Closed 83 | 84 | * [Issue 21](https://github.com/spyder-ide/qtsass/issues/21) - Reorganize qtsass package 85 | * [Issue 18](https://github.com/spyder-ide/qtsass/issues/18) - CLI - build/watch directory ([PR 23](https://github.com/spyder-ide/qtsass/pull/23) by [@danbradham](https://github.com/danbradham)) 86 | * [Issue 17](https://github.com/spyder-ide/qtsass/issues/17) - Create PyPI package ([PR 32](https://github.com/spyder-ide/qtsass/pull/32) by [@goanpeca](https://github.com/goanpeca)) 87 | * [Issue 16](https://github.com/spyder-ide/qtsass/issues/16) - Add Windows CI - Appveyor ([PR 19](https://github.com/spyder-ide/qtsass/pull/19) by [@danbradham](https://github.com/danbradham)) 88 | * [Issue 12](https://github.com/spyder-ide/qtsass/issues/12) - Move test to a tests folder ([PR 13](https://github.com/spyder-ide/qtsass/pull/13) by [@danbradham](https://github.com/danbradham)) 89 | * [Issue 11](https://github.com/spyder-ide/qtsass/issues/11) - Add badges for coverage and travis CI builds ([PR 14](https://github.com/spyder-ide/qtsass/pull/14) by [@danbradham](https://github.com/danbradham)) 90 | * [Issue 10](https://github.com/spyder-ide/qtsass/issues/10) - Add coverage to the CI tests with codecov.io ([PR 15](https://github.com/spyder-ide/qtsass/pull/15) by [@goanpeca](https://github.com/goanpeca)) 91 | * [Issue 9](https://github.com/spyder-ide/qtsass/issues/9) - Add MIT LICENSE.txt file ([PR 2](https://github.com/spyder-ide/qtsass/pull/2) by [@danbradham](https://github.com/danbradham)) 92 | * [Issue 8](https://github.com/spyder-ide/qtsass/issues/8) - Add Travis CI integration for building and running tests ([PR 2](https://github.com/spyder-ide/qtsass/pull/2) by [@danbradham](https://github.com/danbradham)) 93 | * [Issue 7](https://github.com/spyder-ide/qtsass/issues/7) - Create RELEASE.md instructions ([PR 32](https://github.com/spyder-ide/qtsass/pull/32) by [@goanpeca](https://github.com/goanpeca)) 94 | * [Issue 6](https://github.com/spyder-ide/qtsass/issues/6) - Create conda-forge package 95 | * [Issue 5](https://github.com/spyder-ide/qtsass/issues/5) - Create a changelog ([PR 32](https://github.com/spyder-ide/qtsass/pull/32) by [@goanpeca](https://github.com/goanpeca)) 96 | * [Issue 4](https://github.com/spyder-ide/qtsass/issues/4) - Include @import support ([PR 2](https://github.com/spyder-ide/qtsass/pull/2) by [@danbradham](https://github.com/danbradham)) 97 | * [Issue 3](https://github.com/spyder-ide/qtsass/issues/3) - Make package pip installable ([PR 2](https://github.com/spyder-ide/qtsass/pull/2) by [@danbradham](https://github.com/danbradham)) 98 | 99 | In this release 14 issues were closed. 100 | 101 | ### Pull Requests Merged 102 | 103 | * [PR 32](https://github.com/spyder-ide/qtsass/pull/32) - PR: Prepare release, by [@goanpeca](https://github.com/goanpeca) ([7](https://github.com/spyder-ide/qtsass/issues/7), [5](https://github.com/spyder-ide/qtsass/issues/5), [17](https://github.com/spyder-ide/qtsass/issues/17)) 104 | * [PR 23](https://github.com/spyder-ide/qtsass/pull/23) - PR: Add support for compiling directories of QtSass, by [@danbradham](https://github.com/danbradham) ([18](https://github.com/spyder-ide/qtsass/issues/18)) 105 | * [PR 19](https://github.com/spyder-ide/qtsass/pull/19) - PR: windows ci - appveyor, by [@danbradham](https://github.com/danbradham) ([16](https://github.com/spyder-ide/qtsass/issues/16)) 106 | * [PR 15](https://github.com/spyder-ide/qtsass/pull/15) - PR: Add code coverage, by [@goanpeca](https://github.com/goanpeca) ([10](https://github.com/spyder-ide/qtsass/issues/10)) 107 | * [PR 14](https://github.com/spyder-ide/qtsass/pull/14) - PR: Add badges, by [@danbradham](https://github.com/danbradham) ([11](https://github.com/spyder-ide/qtsass/issues/11)) 108 | * [PR 13](https://github.com/spyder-ide/qtsass/pull/13) - PR: Moved tests to qtsass/tests/, by [@danbradham](https://github.com/danbradham) ([12](https://github.com/spyder-ide/qtsass/issues/12)) 109 | * [PR 2](https://github.com/spyder-ide/qtsass/pull/2) - PR: Make project pip installable and add @import support, by [@danbradham](https://github.com/danbradham) ([9](https://github.com/spyder-ide/qtsass/issues/9), [8](https://github.com/spyder-ide/qtsass/issues/8), [4](https://github.com/spyder-ide/qtsass/issues/4), [3](https://github.com/spyder-ide/qtsass/issues/3)) 110 | 111 | In this release 7 pull requests were closed. 112 | -------------------------------------------------------------------------------- /qtsass/conformers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Conform qss to compliant scss and css to valid qss.""" 10 | 11 | # yapf: disable 12 | 13 | from __future__ import absolute_import, print_function 14 | 15 | # Standard library imports 16 | import re 17 | 18 | 19 | # yapf: enable 20 | 21 | 22 | class Conformer(object): 23 | """Base class for all text transformations.""" 24 | 25 | def to_scss(self, qss): 26 | """Transform some qss to valid scss.""" 27 | return NotImplemented 28 | 29 | def to_qss(self, css): 30 | """Transform some css to valid qss.""" 31 | return NotImplemented 32 | 33 | 34 | class NotConformer(Conformer): 35 | """Conform QSS "!" in selectors.""" 36 | 37 | def to_scss(self, qss): 38 | """Replace "!" in selectors with "_qnot_".""" 39 | return qss.replace(':!', ':_qnot_') 40 | 41 | def to_qss(self, css): 42 | """Replace "_qnot_" in selectors with "!".""" 43 | return css.replace(':_qnot_', ':!') 44 | 45 | 46 | class QLinearGradientConformer(Conformer): 47 | """Conform QSS qlineargradient function.""" 48 | 49 | _DEFAULT_COORDS = ('x1', 'y1', 'x2', 'y2') 50 | 51 | qss_pattern = re.compile( 52 | r'qlineargradient\(' 53 | r'((?:(?:\s+)?(?:x1|y1|x2|y2):(?:\s+)?[0-9A-Za-z$_\.-]+,?)+)' # coords 54 | r'((?:(?:\s+)?stop:.*,?)+(?:\s+)?)?' # stops 55 | r'\)', 56 | re.MULTILINE, 57 | ) 58 | 59 | def _conform_coords_to_scss(self, group): 60 | """ 61 | Take a qss str with xy coords and returns the values. 62 | 63 | 'x1: 0, y1: 0, x2: 0, y2: 0' => '0, 0, 0, 0' 64 | 'y1: 1' => '0, 1, 0, 0' 65 | """ 66 | values = ['0', '0', '0', '0'] 67 | for key_values in [part.split(':', 1) for part in group.split(',')]: 68 | try: 69 | key, value = key_values 70 | key = key.strip() 71 | if key in self._DEFAULT_COORDS: 72 | pos = self._DEFAULT_COORDS.index(key) 73 | if pos >= 0 and pos <= 3: 74 | values[pos] = value.strip() 75 | except ValueError: 76 | pass 77 | return ', '.join(values) 78 | 79 | def _conform_stops_to_scss(self, group): 80 | """ 81 | Take a qss str with stops and returns the values. 82 | 83 | 'stop: 0 red, stop: 1 blue' => '0 red, 1 blue' 84 | """ 85 | new_group = [] 86 | split = [""] 87 | bracket_level = 0 88 | for char in group: 89 | if not bracket_level and char == ",": 90 | split.append("") 91 | continue 92 | elif char == "(": 93 | bracket_level += 1 94 | elif char == ")": 95 | bracket_level -= 1 96 | split[-1] += char 97 | 98 | for part in split: 99 | if part: 100 | _, value = part.split(':', 1) 101 | new_group.append(value.strip()) 102 | return ', '.join(new_group) 103 | 104 | def to_scss(self, qss): 105 | """ 106 | Conform qss qlineargradient to scss qlineargradient form. 107 | 108 | Normalize all whitespace including the removal of newline chars. 109 | 110 | qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0, stop: 0 red, stop: 1 blue) 111 | => 112 | qlineargradient(0, 0, 0, 0, (0 red, 1 blue)) 113 | """ 114 | conformed = qss 115 | 116 | for coords, stops in self.qss_pattern.findall(qss): 117 | new_coords = self._conform_coords_to_scss(coords) 118 | conformed = conformed.replace(coords, new_coords, 1) 119 | 120 | if not stops: 121 | continue 122 | 123 | new_stops = ', ({})'.format(self._conform_stops_to_scss(stops)) 124 | conformed = conformed.replace(stops, new_stops, 1) 125 | 126 | return conformed 127 | 128 | def to_qss(self, css): 129 | """Transform to qss from css.""" 130 | return css 131 | 132 | 133 | class QRadialGradientConformer(Conformer): 134 | """Conform QSS qradialgradient function.""" 135 | 136 | _DEFAULT_COORDS = ('cx', 'cy', 'radius', 'fx', 'fy') 137 | 138 | qss_pattern = re.compile( 139 | r'qradialgradient\(' 140 | # spread 141 | r'((?:(?:\s+)?(?:spread):(?:\s+)?[0-9A-Za-z$_\.-]+,?)+)?' 142 | # coords 143 | r'((?:(?:\s+)?(?:cx|cy|radius|fx|fy):(?:\s+)?[0-9A-Za-z$_\.-]+,?)+)' 144 | # stops 145 | r'((?:(?:\s+)?stop:.*,?)+(?:\s+)?)?' 146 | r'\)', 147 | re.MULTILINE, 148 | ) 149 | 150 | def _conform_spread_to_scss(self, group): 151 | """ 152 | Take a qss str with xy coords and returns the values. 153 | 154 | 'spread: pad|repeat|reflect' 155 | """ 156 | value = 'pad' 157 | for key_values in [part.split(':', 1) for part in group.split(',')]: 158 | try: 159 | key, value = key_values 160 | key = key.strip() 161 | if key == 'spread': 162 | value = value.strip() 163 | except ValueError: 164 | pass 165 | return value 166 | 167 | def _conform_coords_to_scss(self, group): 168 | """ 169 | Take a qss str with xy coords and returns the values. 170 | 171 | 'cx: 0, cy: 0, radius: 0, fx: 0, fy: 0' => '0, 0, 0, 0, 0' 172 | 'cy: 1' => '0, 1, 0, 0, 0' 173 | """ 174 | values = ['0', '0', '0', '0', '0'] 175 | for key_values in [part.split(':', 1) for part in group.split(',')]: 176 | try: 177 | key, value = key_values 178 | key = key.strip() 179 | if key in self._DEFAULT_COORDS: 180 | pos = self._DEFAULT_COORDS.index(key) 181 | if pos >= 0: 182 | values[pos] = value.strip() 183 | except ValueError: 184 | pass 185 | return ', '.join(values) 186 | 187 | def _conform_stops_to_scss(self, group): 188 | """ 189 | Take a qss str with stops and returns the values. 190 | 191 | 'stop: 0 red, stop: 1 blue' => '0 red, 1 blue' 192 | """ 193 | new_group = [] 194 | split = [""] 195 | bracket_level = 0 196 | for char in group: 197 | if not bracket_level and char == ",": 198 | split.append("") 199 | continue 200 | elif char == "(": 201 | bracket_level += 1 202 | elif char == ")": 203 | bracket_level -= 1 204 | split[-1] += char 205 | 206 | for part in split: 207 | if part: 208 | _, value = part.split(':', 1) 209 | new_group.append(value.strip()) 210 | return ', '.join(new_group) 211 | 212 | def to_scss(self, qss): 213 | """ 214 | Conform qss qradialgradient to scss qradialgradient form. 215 | 216 | Normalize all whitespace including the removal of newline chars. 217 | 218 | qradialgradient(cx: 0, cy: 0, radius: 0, 219 | fx: 0, fy: 0, stop: 0 red, stop: 1 blue) 220 | => 221 | qradialgradient(0, 0, 0, 0, 0, (0 red, 1 blue)) 222 | """ 223 | conformed = qss 224 | 225 | for spread, coords, stops in self.qss_pattern.findall(qss): 226 | new_spread = "'" + self._conform_spread_to_scss(spread) + "', " 227 | conformed = conformed.replace(spread, new_spread, 1) 228 | new_coords = self._conform_coords_to_scss(coords) 229 | conformed = conformed.replace(coords, new_coords, 1) 230 | if not stops: 231 | continue 232 | 233 | new_stops = ', ({})'.format(self._conform_stops_to_scss(stops)) 234 | conformed = conformed.replace(stops, new_stops, 1) 235 | 236 | return conformed 237 | 238 | def to_qss(self, css): 239 | """Transform to qss from css.""" 240 | return css 241 | 242 | 243 | conformers = [c() for c in Conformer.__subclasses__() if c is not Conformer] 244 | 245 | 246 | def scss_conform(input_str): 247 | """ 248 | Conform qss to valid scss. 249 | 250 | Runs the to_scss method of all Conformer subclasses on the input_str. 251 | Conformers are run in order of definition. 252 | 253 | :param input_str: QSS string 254 | :returns: Valid SCSS string 255 | """ 256 | conformed = input_str 257 | for conformer in conformers: 258 | conformed = conformer.to_scss(conformed) 259 | 260 | return conformed 261 | 262 | 263 | def qt_conform(input_str): 264 | """ 265 | Conform css to valid qss. 266 | 267 | Runs the to_qss method of all Conformer subclasses on the input_str. 268 | Conformers are run in reverse order. 269 | 270 | :param input_str: CSS string 271 | :returns: Valid QSS string 272 | """ 273 | conformed = input_str 274 | for conformer in conformers[::-1]: 275 | conformed = conformer.to_qss(conformed) 276 | 277 | return conformed 278 | -------------------------------------------------------------------------------- /tests/test_conformers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Copyright (c) 2015 Yann Lanthony 4 | # Copyright (c) 2017-2018 Spyder Project Contributors 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (See LICENSE.txt for details) 8 | # ----------------------------------------------------------------------------- 9 | """Test qtsass conformers.""" 10 | 11 | from __future__ import absolute_import 12 | 13 | # Standard library imports 14 | from textwrap import dedent 15 | import unittest 16 | 17 | # Local imports 18 | from qtsass.conformers import ( 19 | NotConformer, 20 | QLinearGradientConformer, 21 | QRadialGradientConformer, 22 | ) 23 | 24 | 25 | class TestNotConformer(unittest.TestCase): 26 | 27 | qss_str = 'QAbstractItemView::item:!active' 28 | css_str = 'QAbstractItemView::item:_qnot_active' 29 | 30 | def test_conform_to_scss(self): 31 | """NotConformer qss to scss.""" 32 | 33 | c = NotConformer() 34 | self.assertEqual(c.to_scss(self.qss_str), self.css_str) 35 | 36 | def test_conform_to_qss(self): 37 | """NotConformer css to qss.""" 38 | 39 | c = NotConformer() 40 | self.assertEqual(c.to_qss(self.css_str), self.qss_str) 41 | 42 | def test_round_trip(self): 43 | """NotConformer roundtrip.""" 44 | 45 | c = NotConformer() 46 | conformed_css = c.to_scss(self.qss_str) 47 | self.assertEqual(c.to_qss(conformed_css), self.qss_str) 48 | 49 | 50 | class TestQLinearGradientConformer(unittest.TestCase): 51 | 52 | css_vars_str = 'qlineargradient($x1, $y1, $x2, $y2, (0 $red, 1 $blue))' 53 | qss_vars_str = ( 54 | 'qlineargradient(x1:$x1, x2:$x2, y1:$y1, y2:$y2' 55 | 'stop: 0 $red, stop: 1 $blue)' 56 | ) 57 | 58 | css_nostops_str = 'qlineargradient(0, 0, 0, 0)' 59 | qss_nostops_str = 'qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0)' 60 | 61 | css_str = 'qlineargradient(0, 0, 0, 0, (0 red, 1 blue))' 62 | qss_singleline_str = ( 63 | 'qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0, ' 64 | 'stop: 0 red, stop: 1 blue)' 65 | ) 66 | qss_multiline_str = dedent(""" 67 | qlineargradient( 68 | x1: 0, 69 | y1: 0, 70 | x2: 0, 71 | y2: 0, 72 | stop: 0 red, 73 | stop: 1 blue 74 | ) 75 | """).strip() 76 | qss_weird_whitespace_str = ( 77 | 'qlineargradient( x1: 0, y1:0, x2: 0, y2:0, ' 78 | ' stop:0 red, stop: 1 blue )' 79 | ) 80 | 81 | css_rgba_str = ( 82 | 'qlineargradient(0, 0, 0, 0, ' 83 | '(0 rgba(0, 1, 2, 30%), 0.99 rgba(7, 8, 9, 100%)))' 84 | ) 85 | qss_rgba_str = ( 86 | 'qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0, ' 87 | 'stop: 0 rgba(0, 1, 2, 30%), stop: 0.99 rgba(7, 8, 9, 100%))' 88 | ) 89 | 90 | css_incomplete_coords_str = ( 91 | 'qlineargradient(0, 1, 0, 0, (0 red, 1 blue))' 92 | ) 93 | 94 | qss_incomplete_coords_str = ( 95 | 'qlineargradient(y1:1, stop:0 red, stop: 1 blue)' 96 | ) 97 | 98 | css_float_coords_str = ( 99 | 'qlineargradient(0, 0.75, 0, 0, (0 green, 1 pink))' 100 | ) 101 | 102 | qss_float_coords_str = ( 103 | 'qlineargradient(y1:0.75, stop:0 green, stop: 1 pink)' 104 | ) 105 | 106 | def test_does_not_affect_css_form(self): 107 | """QLinearGradientConformer no affect on css qlineargradient func.""" 108 | 109 | c = QLinearGradientConformer() 110 | self.assertEqual(c.to_scss(self.css_str), self.css_str) 111 | self.assertEqual(c.to_qss(self.css_str), self.css_str) 112 | 113 | def test_conform_singleline_str(self): 114 | """QLinearGradientConformer singleline qss to scss.""" 115 | 116 | c = QLinearGradientConformer() 117 | self.assertEqual(c.to_scss(self.qss_singleline_str), self.css_str) 118 | 119 | def test_conform_multiline_str(self): 120 | """QLinearGradientConformer multiline qss to scss.""" 121 | 122 | c = QLinearGradientConformer() 123 | self.assertEqual(c.to_scss(self.qss_multiline_str), self.css_str) 124 | 125 | def test_conform_weird_whitespace_str(self): 126 | """QLinearGradientConformer weird whitespace qss to scss.""" 127 | 128 | c = QLinearGradientConformer() 129 | self.assertEqual(c.to_scss(self.qss_weird_whitespace_str), self.css_str) 130 | 131 | def test_conform_nostops_str(self): 132 | """QLinearGradientConformer qss with no stops to scss.""" 133 | 134 | c = QLinearGradientConformer() 135 | self.assertEqual(c.to_scss(self.qss_nostops_str), self.css_nostops_str) 136 | 137 | def test_conform_vars_str(self): 138 | """QLinearGradientConformer qss with vars to scss.""" 139 | 140 | c = QLinearGradientConformer() 141 | self.assertEqual(c.to_scss(self.qss_vars_str), self.css_vars_str) 142 | 143 | def test_conform_rgba_str(self): 144 | """QLinearGradientConformer qss with rgba to scss.""" 145 | 146 | c = QLinearGradientConformer() 147 | self.assertEqual(c.to_scss(self.qss_rgba_str), self.css_rgba_str) 148 | 149 | def test_incomplete_coords(self): 150 | """QLinearGradientConformer qss with not all 4 coordinates given.""" 151 | 152 | c = QLinearGradientConformer() 153 | self.assertEqual(c.to_scss(self.qss_incomplete_coords_str), 154 | self.css_incomplete_coords_str) 155 | 156 | def test_float_coords(self): 157 | c = QLinearGradientConformer() 158 | self.assertEqual(c.to_scss(self.qss_float_coords_str), 159 | self.css_float_coords_str) 160 | 161 | 162 | class TestQRadialGradientConformer(unittest.TestCase): 163 | 164 | css_vars_str = "qradialgradient('$spread', $cx, $cy, $radius, $fx, $fy, (0 $red, 1 $blue))" 165 | qss_vars_str = ( 166 | 'qradialgradient(spread:$spread, cx:$cx, cy:$cy, radius:$radius, fx:$fx, fy:$fy,' 167 | 'stop: 0 $red, stop: 1 $blue)' 168 | ) 169 | 170 | css_nostops_str = "qradialgradient('pad', 0, 0, 0, 0, 0)" 171 | qss_nostops_str = 'qradialgradient(spread: pad, cx: 0, cy: 0, fx: 0, fy: 0)' 172 | 173 | css_str = "qradialgradient('pad', 0, 0, 0, 0, 0, (0 red, 1 blue))" 174 | qss_singleline_str = ( 175 | 'qradialgradient(spread: pad, cx: 0, cy: 0, fx: 0, fy: 0, ' 176 | 'stop: 0 red, stop: 1 blue)' 177 | ) 178 | qss_multiline_str = dedent(""" 179 | qradialgradient( 180 | spread: pad, 181 | cx: 0, 182 | cy: 0, 183 | fx: 0, 184 | fy: 0, 185 | stop: 0 red, 186 | stop: 1 blue 187 | ) 188 | """).strip() 189 | qss_weird_whitespace_str = ( 190 | 'qradialgradient( spread: pad, cx: 0, cy:0, fx: 0, fy:0, ' 191 | ' stop:0 red, stop: 1 blue )' 192 | ) 193 | 194 | css_rgba_str = ( 195 | "qradialgradient('pad', 0, 0, 0, 0, 0, " 196 | "(0 rgba(0, 1, 2, 30%), 0.99 rgba(7, 8, 9, 100%)))" 197 | ) 198 | qss_rgba_str = ( 199 | 'qradialgradient(spread: pad, cx: 0, cy: 0, fx: 0, fy: 0, ' 200 | 'stop: 0 rgba(0, 1, 2, 30%), stop: 0.99 rgba(7, 8, 9, 100%))' 201 | ) 202 | 203 | css_incomplete_coords_str = ( 204 | "qradialgradient('pad', 0, 1, 0, 0, 0, (0 red, 1 blue))" 205 | ) 206 | 207 | qss_incomplete_coords_str = ( 208 | 'qradialgradient(spread:pad, cy:1, stop:0 red, stop: 1 blue)' 209 | ) 210 | 211 | css_float_coords_str = ( 212 | "qradialgradient('pad', 0, 0.75, 0, 0, 0, (0 green, 1 pink))" 213 | ) 214 | 215 | qss_float_coords_str = ( 216 | 'qradialgradient(spread: pad, cy:0.75, stop:0 green, stop: 1 pink)' 217 | ) 218 | 219 | def test_does_not_affect_css_form(self): 220 | """QRadialGradientConformer no affect on css qradialgradient func.""" 221 | 222 | c = QRadialGradientConformer() 223 | self.assertEqual(c.to_scss(self.css_str), self.css_str) 224 | self.assertEqual(c.to_qss(self.css_str), self.css_str) 225 | 226 | def test_conform_singleline_str(self): 227 | """QRadialGradientConformer singleline qss to scss.""" 228 | 229 | c = QRadialGradientConformer() 230 | self.assertEqual(c.to_scss(self.qss_singleline_str), self.css_str) 231 | 232 | def test_conform_multiline_str(self): 233 | """QRadialGradientConformer multiline qss to scss.""" 234 | 235 | c = QRadialGradientConformer() 236 | self.assertEqual(c.to_scss(self.qss_multiline_str), self.css_str) 237 | 238 | def test_conform_weird_whitespace_str(self): 239 | """QRadialGradientConformer weird whitespace qss to scss.""" 240 | 241 | c = QRadialGradientConformer() 242 | self.assertEqual(c.to_scss(self.qss_weird_whitespace_str), self.css_str) 243 | 244 | def test_conform_nostops_str(self): 245 | """QRadialGradientConformer qss with no stops to scss.""" 246 | 247 | c = QRadialGradientConformer() 248 | self.assertEqual(c.to_scss(self.qss_nostops_str), self.css_nostops_str) 249 | 250 | def test_conform_vars_str(self): 251 | """QRadialGradientConformer qss with vars to scss.""" 252 | 253 | c = QRadialGradientConformer() 254 | self.assertEqual(c.to_scss(self.qss_vars_str), self.css_vars_str) 255 | 256 | def test_conform_rgba_str(self): 257 | """QRadialGradientConformer qss with rgba to scss.""" 258 | 259 | c = QRadialGradientConformer() 260 | self.assertEqual(c.to_scss(self.qss_rgba_str), self.css_rgba_str) 261 | 262 | def test_incomplete_coords(self): 263 | """QRadialGradientConformer qss with not all 4 coordinates given.""" 264 | 265 | c = QRadialGradientConformer() 266 | self.assertEqual(c.to_scss(self.qss_incomplete_coords_str), 267 | self.css_incomplete_coords_str) 268 | 269 | def test_float_coords(self): 270 | c = QRadialGradientConformer() 271 | self.assertEqual(c.to_scss(self.qss_float_coords_str), 272 | self.css_float_coords_str) 273 | 274 | 275 | if __name__ == "__main__": 276 | unittest.main(verbosity=2) 277 | -------------------------------------------------------------------------------- /rever.xsh: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import ast 3 | import os 4 | 5 | # Third party imports 6 | from rever.activity import activity 7 | from rever.tools import replace_in_file 8 | 9 | 10 | $ACTIVITIES = [ 11 | 'checkout', 12 | 'clean_repo', 13 | 'update_repo', 14 | 'install_deps', 15 | 'format_code', 16 | 'run_tests', 17 | 'update_release_version', 18 | 'create_python_distributions', 19 | 'upload_test_distributions', 20 | 'install_test_distributions', 21 | 'run_install_tests', 22 | 'create_changelog', 23 | 'commit_release_version', 24 | 'authors', 25 | 'add_tag', 26 | 'upload_python_distributions', 27 | 'update_dev_version', 28 | 'commit_dev_version', 29 | 'push', 30 | ] 31 | 32 | 33 | $PROJECT = "qtsass" 34 | $MODULE = $PROJECT 35 | $GITHUB_ORG = 'spyder-ide' 36 | $GITHUB_REPO = $PROJECT 37 | $VERSION_BUMP_PATTERNS = [ 38 | # These note where/how to find the version numbers 39 | ($MODULE + '/__init__.py', r'__version__\s*=.*', '__version__ = "$VERSION"'), 40 | ] 41 | $AUTHORS_FILENAME = "AUTHORS.md" 42 | $AUTHORS_TEMPLATE = """# Authors 43 | 44 | The $PROJECT project has some great contributors! They are: 45 | 46 | {authors} 47 | 48 | These have been sorted {sorting_text}. 49 | """ 50 | $AUTHORS_FORMAT= "- [{name}](https://github.com/{github})\n" 51 | $AUTHORS_SORTBY = "alpha" 52 | $TEMP_ENV = 'tmp-' + $PROJECT 53 | $CONDA_ACTIVATE_SCRIPT = 'activate.xsh' 54 | $HERE = os.path.abspath(os.path.dirname(__file__)) 55 | 56 | 57 | # --- Helpers 58 | # ---------------------------------------------------------------------------- 59 | class Colors: 60 | HEADER = '\033[95m' 61 | OKBLUE = '\033[94m' 62 | OKGREEN = '\033[92m' 63 | WARNING = '\033[93m' 64 | FAIL = '\033[91m' 65 | ENDC = '\033[0m' 66 | BOLD = '\033[1m' 67 | UNDERLINE = '\033[4m' 68 | 69 | 70 | def cprint(text, color): 71 | """Print colored text.""" 72 | print(color + text + Colors.ENDC) 73 | 74 | 75 | def get_version(version_type, module=$MODULE): 76 | """ 77 | Get version info. Tuple with three items, major.minor.patch 78 | """ 79 | with open(os.path.join($HERE, module, "__init__.py")) as fh: 80 | data = fh.read() 81 | 82 | major, minor, patch = 'MAJOR', 'MINOR', 'PATCH' 83 | lines = data.split("\n") 84 | for line in lines: 85 | if line.startswith("__version__"): 86 | version = ast.literal_eval(line.split("=")[-1].strip()) 87 | major, minor, patch = [int(v) for v in version.split('.')[:3]] 88 | 89 | version_type = version_type.lower() 90 | if version_type == 'major': 91 | major += 1 92 | minor = 0 93 | patch = 0 94 | elif version_type == 'minor': 95 | minor += 1 96 | patch = 0 97 | elif version_type == 'patch': 98 | patch += 1 99 | elif version_type in ['check', 'setup']: 100 | pass 101 | elif len(version_type.split('.')) == 3: 102 | major, minor, patch = version_type.split('.') 103 | else: 104 | raise Exception('Invalid option! Must provide version type: [major|minor|patch|MAJOR.MINOR.PATCH]') 105 | 106 | major = str(major) 107 | minor = str(minor) 108 | patch = str(patch) 109 | version = '.'.join([major, minor, patch]) 110 | 111 | if version_type not in ['check', 'setup']: 112 | cprint('\n\nReleasing version {}'.format(version), Colors.OKBLUE) 113 | print('\n\n') 114 | 115 | return version 116 | 117 | 118 | # Actual versions to use 119 | $NEW_VERSION = get_version($VERSION) 120 | $DEV_VERSION = $NEW_VERSION + '.dev0' 121 | 122 | 123 | def activate(env_name): 124 | """ 125 | Activate a conda environment. 126 | """ 127 | if not os.path.isfile($CONDA_ACTIVATE_SCRIPT): 128 | with open('activate.xsh', 'w') as fh: 129 | fh.write($(conda shell.xonsh hook)) 130 | 131 | # Activate environment 132 | source activate.xsh 133 | conda activate @(env_name) 134 | $[conda info] 135 | 136 | 137 | def update_version(version): 138 | """ 139 | Update version patterns. 140 | """ 141 | for fpath, pattern, new_pattern in $VERSION_BUMP_PATTERNS: 142 | new_pattern = new_pattern.replace('$VERSION', version) 143 | replace_in_file(pattern, new_pattern, fpath) 144 | 145 | 146 | # --- Activities 147 | # ---------------------------------------------------------------------------- 148 | @activity 149 | def checkout(branch='master'): 150 | """ 151 | Checkout master branch. 152 | """ 153 | git stash 154 | git checkout @(branch) 155 | 156 | # Check that origin and remote exist 157 | remotes = $(git remote -v) 158 | if not ('origin' in remotes and 'upstream' in remotes): 159 | raise Exception('Must have git remotes origin (pointing to fork) and upstream (pointing to repo)') 160 | 161 | 162 | @activity 163 | def clean_repo(): 164 | """ 165 | Clean the repo from build/dist and other files. 166 | """ 167 | import pathlib 168 | 169 | # Remove python files 170 | for p in pathlib.Path('.').rglob('*.py[co]'): 171 | p.unlink() 172 | 173 | for p in pathlib.Path('.').rglob('*.orig'): 174 | p.unlink() 175 | 176 | for p in pathlib.Path('.').rglob('__pycache__'): 177 | p.rmdir() 178 | 179 | rm -rf CHANGELOG.temp 180 | rm -rf .pytest_cache/ 181 | rm -rf build/ 182 | rm -rf dist/ 183 | rm -rf activate.xsh 184 | rm -rf $MODULE.egg-info 185 | 186 | # Delete files not tracked by git? 187 | # git clean -xfd 188 | 189 | 190 | @activity 191 | def update_repo(branch='master'): 192 | """ 193 | Stash any current changes and ensure you have the latest version from origin. 194 | """ 195 | git stash 196 | git pull upstream @(branch) 197 | git push origin @(branch) 198 | 199 | 200 | @activity 201 | def install_deps(): 202 | """ 203 | Install release and test dependencies. 204 | """ 205 | try: 206 | conda remove --name $TEMP_ENV --yes --quiet --all 207 | except: 208 | pass 209 | 210 | conda create --name $TEMP_ENV python=3.7 --yes --quiet 211 | activate($TEMP_ENV) 212 | pip install -r requirements/install.txt 213 | pip install -r requirements/dev.txt 214 | pip install -r requirements/release.txt 215 | 216 | 217 | @activity 218 | def format_code(): 219 | """ 220 | Format code. 221 | """ 222 | activate($TEMP_ENV) 223 | try: 224 | python run_checks_and_format.py 225 | except Exception: 226 | pass 227 | 228 | 229 | @activity 230 | def run_tests(): 231 | """ 232 | Run simple import tests before cleaning repository. 233 | """ 234 | pytest tests --cov=$MODULE 235 | 236 | 237 | @activity 238 | def update_release_version(): 239 | """ 240 | Update version in `__init__.py` (set release version, remove 'dev0'). 241 | and on the package.json file. 242 | """ 243 | update_version($NEW_VERSION) 244 | 245 | 246 | @activity 247 | def create_python_distributions(): 248 | """ 249 | Create distributions. 250 | """ 251 | activate($TEMP_ENV) 252 | python setup.py sdist bdist_wheel 253 | 254 | 255 | @activity 256 | def upload_test_distributions(): 257 | """ 258 | Upload test distributions. 259 | """ 260 | activate($TEMP_ENV) 261 | cprint("Yow will be asked to provide credentials", Colors.OKBLUE) 262 | print("\n\n") 263 | 264 | # The file might be already there 265 | try: 266 | python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* 267 | except Exception as err: 268 | print(err) 269 | 270 | 271 | @activity 272 | def install_test_distributions(): 273 | """ 274 | Upload test distributions. 275 | """ 276 | activate($TEMP_ENV) 277 | 278 | # Python package 279 | pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple $PROJECT==$NEW_VERSION 280 | 281 | 282 | @activity 283 | def run_install_tests(): 284 | """ 285 | Run simple import tests before cleaning repository. 286 | """ 287 | activate($TEMP_ENV) 288 | $MODULE --help 289 | 290 | 291 | @activity 292 | def create_changelog(): 293 | """ 294 | Create changelog using loghub. 295 | """ 296 | loghub $GITHUB_ORG/$GITHUB_REPO -zr @($PROJECT + ' v' + $NEW_VERSION) 297 | 298 | with open('CHANGELOG.temp', 'r') as fh: 299 | new_changelog_lines = fh.read().split('\n') 300 | 301 | with open('CHANGELOG.md', 'r') as fh: 302 | lines = fh.read().split('\n') 303 | 304 | new_lines = lines[:2] + new_changelog_lines + lines[2:] 305 | 306 | with open('CHANGELOG.md', 'w') as fh: 307 | fh.write('\n'.join(new_lines)) 308 | 309 | 310 | @activity 311 | def commit_release_version(): 312 | """ 313 | Commit release version. 314 | """ 315 | git add . 316 | git commit -m @('Set release version to ' + $NEW_VERSION + ' [ci skip]') 317 | 318 | 319 | @activity 320 | def add_tag(): 321 | """ 322 | Add release tag. 323 | """ 324 | # TODO: Add check to see if tag already exists? 325 | git tag -a @('v' + $NEW_VERSION) -m @('Tag version ' + $NEW_VERSION + ' [ci skip]') 326 | 327 | 328 | @activity 329 | def upload_python_distributions(): 330 | """ 331 | Upload the distributions to pypi production environment. 332 | """ 333 | activate($TEMP_ENV) 334 | cprint("Yow will be asked to provide credentials", Colors.OKBLUE) 335 | print("\n\n") 336 | 337 | # The file might be already there 338 | try: 339 | twine upload dist/* 340 | except Exception as err: 341 | print(err) 342 | 343 | 344 | @activity 345 | def update_dev_version(): 346 | """ 347 | Update `__init__.py` (add 'dev0'). 348 | """ 349 | update_version($DEV_VERSION) 350 | 351 | 352 | @activity 353 | def commit_dev_version(): 354 | """" 355 | Commit dev changes. 356 | """ 357 | git add . 358 | git commit -m "Restore dev version [ci skip]" --no-verify 359 | 360 | 361 | @activity 362 | def push(branch='master'): 363 | """ 364 | Push changes. 365 | """ 366 | # Push changes 367 | git push origin @(branch) 368 | git push upstream @(branch) 369 | 370 | # Push tags 371 | git push origin --tags 372 | git push upstream --tags 373 | --------------------------------------------------------------------------------