@@ -8,18 +8,39 @@
Loading
8 8
# -----------------------------------------------------------------------------
9 9
"""Libsass functions."""
10 10
11 +
# yapf: disable
12 +
11 13
# Third party imports
12 14
import sass
13 15
14 16
17 +
# yapf: enable
18 +
19 +
15 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 +
    """
16 30
    result = 'rgba({}, {}, {}, {}%)'
17 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
18 39
        return result.format(
19 40
            int(r.value),
20 41
            int(g.value),
21 42
            int(b.value),
22 -
            int(a.value * 100)
43 +
            int(alpha),
23 44
        )
24 45
    elif isinstance(r, float):
25 46
        return result.format(int(r), int(g), int(b), int(a * 100))
@@ -27,16 +48,20 @@
Loading
27 48
28 49
def rgba_from_color(color):
29 50
    """
30 -
    Conform rgba
51 +
    Conform rgba.
31 52
32 53
    :type color: sass.SassColor
33 54
    """
55 +
    # Inner rgba() call
56 +
    if not isinstance(color, sass.SassColor):
57 +
        return '{}'.format(color)
58 +
34 59
    return rgba(color.r, color.g, color.b, color.a)
35 60
36 61
37 62
def qlineargradient(x1, y1, x2, y2, stops):
38 63
    """
39 -
    Implementation of qss qlineargradient function for scss.
64 +
    Implement qss qlineargradient function for scss.
40 65
41 66
    :type x1: sass.SassNumber
42 67
    :type y1: sass.SassNumber
@@ -45,15 +70,13 @@
Loading
45 70
    :type stops: sass.SassList
46 71
    :return:
47 72
    """
48 -
    stops_str = ''
73 +
    stops_str = []
49 74
    for stop in stops[0]:
50 75
        pos, color = stop[0]
51 -
        stops_str += ' stop: {} {}'.format(pos.value, rgba_from_color(color))
52 -
53 -
    return 'qlineargradient(x1: {}, y1: {}, x2: {}, y2: {},{})'.format(
54 -
        x1.value,
55 -
        y1.value,
56 -
        x2.value,
57 -
        y2.value,
58 -
        stops_str.rstrip(',')
59 -
    )
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))

@@ -9,59 +9,100 @@
Loading
9 9
# -----------------------------------------------------------------------------
10 10
"""qtsass command line interface."""
11 11
12 -
# Standard library imports
12 +
# yapf: disable
13 +
13 14
from __future__ import absolute_import, print_function
15 +
16 +
# Standard library imports
14 17
import argparse
18 +
import logging
15 19
import os
16 20
import sys
17 21
import time
18 -
import logging
19 -
import signal
20 22
21 23
# Local imports
22 -
from qtsass.api import compile, compile_filename, compile_dirname, watch
24 +
from qtsass.api import (
25 +
    compile,
26 +
    compile_dirname,
27 +
    compile_filename,
28 +
    enable_logging,
29 +
    watch,
30 +
)
31 +
23 32
33 +
# yapf: enable
24 34
25 -
logging.basicConfig(level=logging.DEBUG)
26 35
_log = logging.getLogger(__name__)
27 36
28 37
29 38
def create_parser():
30 39
    """Create qtsass's cli parser."""
31 -
32 40
    parser = argparse.ArgumentParser(
33 41
        prog='QtSASS',
34 42
        description='Compile a Qt compliant CSS file from a SASS stylesheet.',
35 43
    )
36 -
    parser.add_argument('input', type=str, help='The SASS stylesheet file.')
44 +
    parser.add_argument(
45 +
        'input',
46 +
        type=str,
47 +
        help='The SASS stylesheet file.',
48 +
    )
37 49
    parser.add_argument(
38 50
        '-o',
39 51
        '--output',
40 52
        type=str,
41 -
        help='The path of the generated Qt compliant CSS file.'
53 +
        help='The path of the generated Qt compliant CSS file.',
42 54
    )
43 55
    parser.add_argument(
44 56
        '-w',
45 57
        '--watch',
46 58
        action='store_true',
47 -
        help='If set, recompile when the source file changes.'
59 +
        help='If set, recompile when the source file changes.',
60 +
    )
61 +
    parser.add_argument(
62 +
        '-d',
63 +
        '--debug',
64 +
        action='store_true',
65 +
        help='Set the logging level to DEBUG.',
48 66
    )
49 67
    return parser
50 68
51 69
52 -
def main(args):
53 -
    """qtsass's cli entry point."""
70 +
def main():
71 +
    """CLI entry point."""
72 +
    args = create_parser().parse_args()
73 +
74 +
    # Setup CLI logging
75 +
    debug = os.environ.get('QTSASS_DEBUG', args.debug)
76 +
    if debug in ('1', 'true', 'True', 'TRUE', 'on', 'On', 'ON', True):
77 +
        level = logging.DEBUG
78 +
    else:
79 +
        level = logging.INFO
80 +
    enable_logging(level)
81 +
82 +
    # Add a StreamHandler
83 +
    handler = logging.StreamHandler()
84 +
    if level == logging.DEBUG:
85 +
        fmt = '%(levelname)-8s: %(name)s> %(message)s'
86 +
        handler.setFormatter(logging.Formatter(fmt))
87 +
    logging.root.addHandler(handler)
88 +
    logging.root.setLevel(level)
54 89
55 -
    args = create_parser().parse_args(args)
56 90
    file_mode = os.path.isfile(args.input)
57 91
    dir_mode = os.path.isdir(args.input)
58 92
59 93
    if file_mode and not args.output:
60 -
        css = compile(args.input)
94 +
        with open(args.input, 'r') as f:
95 +
            string = f.read()
96 +
97 +
        css = compile(
98 +
            string,
99 +
            include_paths=os.path.abspath(os.path.dirname(args.input)),
100 +
        )
61 101
        print(css)
62 102
        sys.exit(0)
63 103
64 104
    elif file_mode:
105 +
        _log.debug('compile_filename({}, {})'.format(args.input, args.output))
65 106
        compile_filename(args.input, args.output)
66 107
67 108
    elif dir_mode and not args.output:
@@ -69,6 +110,7 @@
Loading
69 110
        sys.exit(1)
70 111
71 112
    elif dir_mode:
113 +
        _log.debug('compile_dirname({}, {})'.format(args.input, args.output))
72 114
        compile_dirname(args.input, args.output)
73 115
74 116
    else:
@@ -77,12 +119,13 @@
Loading
77 119
78 120
    if args.watch:
79 121
        _log.info('qtsass is watching {}...'.format(args.input))
80 -
        observer = watch(args.input, args.output)
81 -
        observer.start()
122 +
123 +
        watcher = watch(args.input, args.output)
124 +
        watcher.start()
82 125
        try:
83 126
            while True:
84 -
                time.sleep(1)
127 +
                time.sleep(0.5)
85 128
        except KeyboardInterrupt:
86 -
            observer.stop()
87 -
        observer.join()
129 +
            watcher.stop()
130 +
        watcher.join()
88 131
        sys.exit(0)

@@ -8,28 +8,36 @@
Loading
8 8
# -----------------------------------------------------------------------------
9 9
"""Libsass importers."""
10 10
11 -
# Standard library imports
11 +
# yapf: disable
12 +
12 13
from __future__ import absolute_import
14 +
15 +
# Standard library imports
13 16
import os
14 17
15 18
# Local imports
16 19
from qtsass.conformers import scss_conform
17 20
18 21
22 +
# yapf: enable
23 +
24 +
19 25
def norm_path(*parts):
20 -
    return os.path.normpath(os.path.join(*parts))
26 +
    """Normalize path."""
27 +
    return os.path.normpath(os.path.join(*parts)).replace('\\', '/')
21 28
22 29
23 -
def qss_importer(where):
30 +
def qss_importer(*include_paths):
24 31
    """
25 -
    Returns a function which conforms imported qss files to valid scss to be
26 -
    used as an importer for sass.compile.
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.
27 35
28 -
    :param where: Directory containing scss, css, and sass files
36 +
    :param include_paths: Directorys containing scss, css, and sass files.
29 37
    """
38 +
    include_paths
30 39
31 40
    def find_file(import_file):
32 -
33 41
        # Create partial import filename
34 42
        dirname, basename = os.path.split(import_file)
35 43
        if dirname:
@@ -44,8 +52,9 @@
Loading
44 52
            partial_name = import_partial_file + ext
45 53
            potential_files.append(full_name)
46 54
            potential_files.append(partial_name)
47 -
            potential_files.append(norm_path(where, full_name))
48 -
            potential_files.append(norm_path(where, 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))
49 58
50 59
        # Return first existing potential file
51 60
        for potential_file in potential_files:
@@ -55,7 +64,7 @@
Loading
55 64
        return None
56 65
57 66
    def import_and_conform_file(import_file):
58 -
67 +
        """Return base file and conformed scss file."""
59 68
        real_import_file = find_file(import_file)
60 69
        with open(real_import_file, 'r') as f:
61 70
            import_str = f.read()

@@ -0,0 +1,31 @@
Loading
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

@@ -0,0 +1,96 @@
Loading
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()

@@ -0,0 +1,126 @@
Loading
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()

@@ -8,22 +8,28 @@
Loading
8 8
# -----------------------------------------------------------------------------
9 9
"""Conform qss to compliant scss and css to valid qss."""
10 10
11 -
# Standard library imports
11 +
# yapf: disable
12 +
12 13
from __future__ import absolute_import, print_function
14 +
15 +
# Standard library imports
13 16
import re
14 17
15 18
19 +
# yapf: enable
20 +
21 +
_DEFAULT_COORDS = ('x1', 'y1', 'x2', 'y2')
22 +
23 +
16 24
class Conformer(object):
17 25
    """Base class for all text transformations."""
18 26
19 27
    def to_scss(self, qss):
20 28
        """Transform some qss to valid scss."""
21 -
22 29
        return NotImplemented
23 30
24 31
    def to_qss(self, css):
25 32
        """Transform some css to valid qss."""
26 -
27 33
        return NotImplemented
28 34
29 35
@@ -31,13 +37,11 @@
Loading
31 37
    """Conform QSS "!" in selectors."""
32 38
33 39
    def to_scss(self, qss):
34 -
        """Replaces "!" in selectors with "_qnot_"."""
35 -
40 +
        """Replace "!" in selectors with "_qnot_"."""
36 41
        return qss.replace(':!', ':_qnot_')
37 42
38 43
    def to_qss(self, css):
39 -
        """Replaces "_qnot_" in selectors with "!"."""
40 -
44 +
        """Replace "_qnot_" in selectors with "!"."""
41 45
        return css.replace(':_qnot_', ':!')
42 46
43 47
@@ -45,25 +49,55 @@
Loading
45 49
    """Conform QSS qlineargradient function."""
46 50
47 51
    qss_pattern = re.compile(
48 -
        'qlineargradient\('
49 -
        '((?:(?:\s+)?(?:x1|y1|x2|y2):(?:\s+)?[0-9A-Za-z$_-]+,?)+)'  # coords
50 -
        '((?:(?:\s+)?stop:.*,?)+(?:\s+)?)?'  # stops
51 -
        '\)',
52 -
        re.MULTILINE
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,
53 57
    )
54 58
55 -
    def _conform_group_to_scss(self, group):
59 +
    def _conform_coords_to_scss(self, group):
56 60
        """
57 -
        Takes a qss str containing xy coords or stops and returns a str
58 -
        containing just the values.
61 +
        Take a qss str with xy coords and returns the values.
59 62
60 -
        'x1: 0, y1: 0, x2: 0, y2: 0' => '0, 0, 0, 0'
61 -
        'stop: 0 red, stop: 1 blue' => '0 red, 1 blue'
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 _DEFAULT_COORDS:
72 +
                    pos = _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'
62 84
        """
63 85
        new_group = []
64 -
        for part in group.strip().split(','):
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:
65 99
            if part:
66 -
                _, value = part.split(':')
100 +
                _, value = part.split(':', 1)
67 101
                new_group.append(value.strip())
68 102
        return ', '.join(new_group)
69 103
@@ -71,31 +105,28 @@
Loading
71 105
        """
72 106
        Conform qss qlineargradient to scss qlineargradient form.
73 107
74 -
        Normalizes all whitespace including the removal of newline chars.
108 +
        Normalize all whitespace including the removal of newline chars.
75 109
76 110
        qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0, stop: 0 red, stop: 1 blue)
77 111
        =>
78 112
        qlineargradient(0, 0, 0, 0, (0 red, 1 blue))
79 113
        """
80 -
81 114
        conformed = qss
82 115
83 116
        for coords, stops in self.qss_pattern.findall(qss):
84 -
85 -
            new_coords = self._conform_group_to_scss(coords)
117 +
            new_coords = self._conform_coords_to_scss(coords)
86 118
            conformed = conformed.replace(coords, new_coords, 1)
87 119
88 120
            if not stops:
89 121
                continue
90 122
91 -
            new_stops = ', ({})'.format(self._conform_group_to_scss(stops))
123 +
            new_stops = ', ({})'.format(self._conform_stops_to_scss(stops))
92 124
            conformed = conformed.replace(stops, new_stops, 1)
93 125
94 126
        return conformed
95 127
96 128
    def to_qss(self, css):
97 -
        """Handled by qlineargradient function passed to sass.compile"""
98 -
129 +
        """Transform to qss from css."""
99 130
        return css
100 131
101 132
@@ -112,7 +143,6 @@
Loading
112 143
    :param input_str: QSS string
113 144
    :returns: Valid SCSS string
114 145
    """
115 -
116 146
    conformed = input_str
117 147
    for conformer in conformers:
118 148
        conformed = conformer.to_scss(conformed)
@@ -130,7 +160,6 @@
Loading
130 160
    :param input_str: CSS string
131 161
    :returns: Valid QSS string
132 162
    """
133 -
134 163
    conformed = input_str
135 164
    for conformer in conformers[::-1]:
136 165
        conformed = conformer.to_qss(conformed)

@@ -8,81 +8,207 @@
Loading
8 8
# -----------------------------------------------------------------------------
9 9
"""qtsass - Compile SCSS files to valid Qt stylesheets."""
10 10
11 -
# Standard library imports
11 +
# yapf: disable
12 +
12 13
from __future__ import absolute_import, print_function
14 +
15 +
# Standard library imports
13 16
import logging
14 17
import os
18 +
import sys
15 19
16 20
# Third party imports
17 21
import sass
18 -
from watchdog.observers import Observer
19 22
20 23
# Local imports
21 -
from qtsass.conformers import scss_conform, qt_conform
24 +
from qtsass.conformers import qt_conform, scss_conform
22 25
from qtsass.functions import qlineargradient, rgba
23 26
from qtsass.importers import qss_importer
24 -
from qtsass.events import SourceEventHandler
25 27
26 28
27 -
logging.basicConfig(level=logging.DEBUG)
29 +
if sys.version_info[0] == 3:
30 +
    from collections.abc import Mapping, Sequence
31 +
else:
32 +
    from collections import Mapping, Sequence
33 +
34 +
35 +
# yapf: enable
36 +
37 +
# Constants
38 +
DEFAULT_CUSTOM_FUNCTIONS = {'qlineargradient': qlineargradient, 'rgba': rgba}
39 +
DEFAULT_SOURCE_COMMENTS = False
40 +
41 +
# Logger setup
28 42
_log = logging.getLogger(__name__)
29 43
30 44
31 -
def compile(input_file):
32 -
    """Compile QtSASS to CSS."""
45 +
def compile(string, **kwargs):
46 +
    """
47 +
    Conform and Compile QtSASS source code to CSS.
33 48
34 -
    _log.debug('Compiling {}...'.format(input_file))
49 +
    This function conforms QtSASS to valid SCSS before passing it to
50 +
    sass.compile. Any keyword arguments you provide will be combined with
51 +
    qtsass's default keyword arguments and passed to sass.compile.
35 52
36 -
    with open(input_file, 'r') as f:
37 -
        input_str = f.read()
53 +
    .. code-block:: python
54 +
55 +
        >>> import qtsass
56 +
        >>> qtsass.compile("QWidget {background: rgb(0, 0, 0);}")
57 +
        QWidget {background:black;}
38 58
59 +
    :param string: QtSASS source code to conform and compile.
60 +
    :param kwargs: Keyword arguments to pass to sass.compile
61 +
    :returns: CSS string
62 +
    """
63 +
    kwargs.setdefault('source_comments', DEFAULT_SOURCE_COMMENTS)
64 +
    kwargs.setdefault('custom_functions', [])
65 +
    kwargs.setdefault('importers', [])
66 +
    kwargs.setdefault('include_paths', [])
67 +
68 +
    # Add QtSass importers
69 +
    if isinstance(kwargs['importers'], Sequence):
70 +
        kwargs['importers'] = (list(kwargs['importers']) +
71 +
                               [(0, qss_importer(*kwargs['include_paths']))])
72 +
    else:
73 +
        raise ValueError('Expected Sequence for importers '
74 +
                         'got {}'.format(type(kwargs['importers'])))
75 +
76 +
    # Add QtSass custom_functions
77 +
    if isinstance(kwargs['custom_functions'], Sequence):
78 +
        kwargs['custom_functions'] = dict(
79 +
            DEFAULT_CUSTOM_FUNCTIONS,
80 +
            **{fn.__name__: fn
81 +
               for fn in kwargs['custom_functions']})
82 +
    elif isinstance(kwargs['custom_functions'], Mapping):
83 +
        kwargs['custom_functions'].update(DEFAULT_CUSTOM_FUNCTIONS)
84 +
    else:
85 +
        raise ValueError('Expected Sequence or Mapping for custom_functions '
86 +
                         'got {}'.format(type(kwargs['custom_functions'])))
87 +
88 +
    # Conform QtSass source code
89 +
    try:
90 +
        kwargs['string'] = scss_conform(string)
91 +
    except Exception:
92 +
        _log.error('Failed to conform source code')
93 +
        raise
94 +
95 +
    if _log.isEnabledFor(logging.DEBUG):
96 +
        from pprint import pformat
97 +
        log_kwargs = dict(kwargs)
98 +
        log_kwargs['string'] = 'Conformed SCSS<...>'
99 +
        _log.debug('Calling sass.compile with:')
100 +
        _log.debug(pformat(log_kwargs))
101 +
        _log.debug('Conformed scss:\n{}'.format(kwargs['string']))
102 +
103 +
    # Compile QtSass source code
39 104
    try:
40 -
        importer_root = os.path.dirname(os.path.abspath(input_file))
41 -
        return qt_conform(
42 -
            sass.compile(
43 -
                string=scss_conform(input_str),
44 -
                source_comments=False,
45 -
                custom_functions={
46 -
                    'qlineargradient': qlineargradient,
47 -
                    'rgba': rgba
48 -
                },
49 -
                importers=[(0, qss_importer(importer_root))]
50 -
            )
51 -
        )
52 -
    except sass.CompileError as e:
53 -
        _log.error('Failed to compile {}:\n{}'.format(input_file, e))
54 -
    return ""
55 -
56 -
57 -
def compile_filename(input_file, dest_file):
58 -
    """Compile QtSASS to CSS and save."""
59 -
60 -
    css = compile(input_file)
61 -
    with open(dest_file, 'w') as css_file:
105 +
        return qt_conform(sass.compile(**kwargs))
106 +
    except sass.CompileError:
107 +
        _log.error('Failed to compile source code')
108 +
        raise
109 +
110 +
111 +
def compile_filename(input_file, output_file, **kwargs):
112 +
    """Compile and save QtSASS file as CSS.
113 +
114 +
    .. code-block:: python
115 +
116 +
        >>> import qtsass
117 +
        >>> qtsass.compile_filename("dummy.scss", "dummy.css")
118 +
119 +
    :param input_file: Path to QtSass file.
120 +
    :param output_file: 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 +
    output_root = os.path.abspath(os.path.dirname(output_file))
134 +
    if not os.path.isdir(output_root):
135 +
        os.makedirs(output_root)
136 +
137 +
    with open(output_file, 'w') as css_file:
62 138
        css_file.write(css)
63 -
        _log.info('Created CSS file {}'.format(dest_file))
139 +
        _log.info('Created CSS file {}'.format(os.path.normpath(output_file)))
140 +
141 +
    return css
64 142
65 143
66 -
def compile_dirname(input_dir, output_dir):
67 -
    """Compiles QtSASS files in a directory including subdirectories."""
144 +
def compile_dirname(input_dir, output_dir, **kwargs):
145 +
    """Compiles QtSASS files in a directory including subdirectories.
68 146
69 -
    def is_valid(file):
70 -
        return not file.startswith('_') and file.endswith('.scss')
147 +
    .. code-block:: python
71 148
72 -
    for root, subdirs, files in os.walk(input_dir):
149 +
        >>> import qtsass
150 +
        >>> qtsass.compile_dirname("./scss", "./css")
151 +
152 +
    :param input_dir: Directory containing QtSass files.
153 +
    :param output_dir: Directory to write compiled Qt compliant CSS files to.
154 +
    :param kwargs: Keyword arguments to pass to sass.compile
155 +
    """
156 +
    kwargs.setdefault('include_paths', [input_dir])
157 +
158 +
    def is_valid(file_name):
159 +
        return not file_name.startswith('_') and file_name.endswith('.scss')
160 +
161 +
    for root, _, files in os.walk(input_dir):
73 162
        relative_root = os.path.relpath(root, input_dir)
74 163
        output_root = os.path.join(output_dir, relative_root)
164 +
        fkwargs = dict(kwargs)
165 +
        fkwargs['include_paths'] = fkwargs['include_paths'] + [root]
75 166
76 -
        for file in [f for f in files if is_valid(f)]:
77 -
            scss_path = os.path.join(root, file)
78 -
            css_file = os.path.splitext(file)[0] + '.css'
167 +
        for file_name in [f for f in files if is_valid(f)]:
168 +
            scss_path = os.path.join(root, file_name)
169 +
            css_file = os.path.splitext(file_name)[0] + '.css'
79 170
            css_path = os.path.join(output_root, css_file)
171 +
80 172
            if not os.path.isdir(output_root):
81 173
                os.makedirs(output_root)
82 -
            compile_filename(scss_path, css_path)
83 174
175 +
            compile_filename(scss_path, css_path, **fkwargs)
84 176
85 -
def watch(source, destination, compiler=None, recursive=True):
177 +
178 +
def enable_logging(level=None, handler=None):
179 +
    """Enable logging for qtsass.
180 +
181 +
    Sets the qtsass logger's level to:
182 +
        1. the provided logging level
183 +
        2. logging.DEBUG if the QTSASS_DEBUG envvar is a True value
184 +
        3. logging.WARNING
185 +
186 +
    .. code-block:: python
187 +
        >>> import logging
188 +
        >>> import qtsass
189 +
        >>> handler = logging.StreamHandler()
190 +
        >>> formatter = logging.Formatter('%(level)-8s: %(name)s> %(message)s')
191 +
        >>> handler.setFormatter(formatter)
192 +
        >>> qtsass.enable_logging(level=logging.DEBUG, handler=handler)
193 +
194 +
    :param level: Optional logging level
195 +
    :param handler: Optional handler to add
196 +
    """
197 +
    if level is None:
198 +
        debug = os.environ.get('QTSASS_DEBUG', False)
199 +
        if debug in ('1', 'true', 'True', 'TRUE', 'on', 'On', 'ON'):
200 +
            level = logging.DEBUG
201 +
        else:
202 +
            level = logging.WARNING
203 +
204 +
    logger = logging.getLogger('qtsass')
205 +
    logger.setLevel(level)
206 +
    if handler:
207 +
        logger.addHandler(handler)
208 +
    _log.debug('logging level set to {}.'.format(level))
209 +
210 +
211 +
def watch(source, destination, compiler=None, Watcher=None):
86 212
    """
87 213
    Watches a source file or directory, compiling QtSass files when modified.
88 214
@@ -92,19 +218,20 @@
Loading
92 218
    :param source: Path to source QtSass file or directory.
93 219
    :param destination: Path to output css file or directory.
94 220
    :param compiler: Compile function (optional)
95 -
    :param recursive: If True, watch subdirectories (default: True).
96 -
    :returns: watchdog.Observer
221 +
    :param Watcher: Defaults to qtsass.watchers.Watcher (optional)
222 +
    :returns: qtsass.watchers.Watcher instance
97 223
    """
98 -
99 224
    if os.path.isfile(source):
100 225
        watch_dir = os.path.dirname(source)
101 226
        compiler = compiler or compile_filename
102 -
    else:
227 +
    elif os.path.isdir(source):
103 228
        watch_dir = source
104 229
        compiler = compiler or compile_dirname
230 +
    else:
231 +
        raise ValueError('source arg must be a dirname or filename...')
105 232
106 -
    event_handler = SourceEventHandler(source, destination, compiler)
233 +
    if Watcher is None:
234 +
        from qtsass.watchers import Watcher
107 235
108 -
    observer = Observer()
109 -
    observer.schedule(event_handler, watch_dir, recursive=recursive)
110 -
    return observer
236 +
    watcher = Watcher(watch_dir, compiler, (source, destination))
237 +
    return watcher

@@ -6,13 +6,56 @@
Loading
6 6
# Licensed under the terms of the MIT License
7 7
# (See LICENSE.txt for details)
8 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
9 21
10 -
# Standard library imports
11 22
from __future__ import absolute_import
12 23
24 +
# Standard library imports
25 +
import logging
26 +
13 27
# Local imports
14 -
from qtsass.api import compile, compile_filename, compile_dirname, watch
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.3.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)
15 59
16 60
17 -
VERSION_INFO = (0, 1, 0)
18 -
__version__ = '.'.join(map(str, VERSION_INFO))
61 +
VERSION_INFO = _to_version_info(__version__)

@@ -0,0 +1,64 @@
Loading
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

@@ -0,0 +1,146 @@
Loading
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)

@@ -9,13 +9,15 @@
Loading
9 9
# -----------------------------------------------------------------------------
10 10
"""qtsass command line interface."""
11 11
12 -
# Standard library imports
12 +
# yapf: disable
13 +
13 14
from __future__ import absolute_import
14 -
import sys
15 15
16 16
# Local imports
17 17
from qtsass import cli
18 18
19 19
20 +
# yapf: enable
21 +
20 22
if __name__ == '__main__':
21 -
    cli.main(sys.argv[1:])
23 +
    cli.main()
Files Coverage
qtsass 91.23%
Project Totals (12 files) 91.23%
Sunburst
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Icicle
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.
Grid
Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively.
Loading