Repository: spyder-ide/qtsass

Issue:
CLI - build/watch directory
The current CLI only supports building and watching a single sass file. This may be suitable for small projects, but, large projects tend to have more varied structures. For example, a project may have a base stylesheet with dark and light variants.

- /scss
  - _base.scss
  - dark.scss
  - light.scss

To support the above scenario we should add a CLI option -d/--directory that will compile and watch for changes in all the scss files contained in the directory except for those starting with an underscore. The -d/--directory and the -f/--file options should be mutually exclusive raising an error when both are provided. When using the -d flag the -o flag specifies an output directory for the compiled css.

Valid usages
```
qtsass -d scss -o css
qtsass -d scss -o css -w
qtsass -f scss/dark.scss -o css/dark.css
qtsass -f scss/dark.scss -o css/dark.css -w
```

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 8943659..a2d1ea3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -260,4 +260,7 @@ atlassian-ide-plugin.xml
 # Crashlytics plugin (for Android Studio and IntelliJ)
 com_crashlytics_export_strings.xml
 crashlytics.properties
-crashlytics-build.properties
\ No newline at end of file
+crashlytics-build.properties
+
+# pytest
+.pytest_cache
diff --git a/examples/complex/_base.scss b/examples/complex/_base.scss
new file mode 100644
index 0000000..957d676
--- /dev/null
+++ b/examples/complex/_base.scss
@@ -0,0 +1,4 @@
+@import 'defaults';
+@import 'widgets/qwidget';
+@import 'widgets/qpushbutton';
+@import 'widgets/qlineedit';
diff --git a/examples/complex/_defaults.scss b/examples/complex/_defaults.scss
new file mode 100644
index 0000000..931feda
--- /dev/null
+++ b/examples/complex/_defaults.scss
@@ -0,0 +1,13 @@
+// Fonts
+$font-stack: Helvetica, sans-serif !default;
+
+// Colors
+$background: rgb(255, 255, 255) !default;
+$primary: rgb(35, 35, 35) !default;
+$accent: rgb(35, 75, 135) !default;
+
+// Paddings
+$text-padding: 16px 8px 16px 8px !default;
+
+// Borders
+$border-radius: 2px !default;
diff --git a/examples/complex/dark.scss b/examples/complex/dark.scss
new file mode 100644
index 0000000..579b607
--- /dev/null
+++ b/examples/complex/dark.scss
@@ -0,0 +1,9 @@
+// Change default values
+
+$background: rgb(35, 35, 35);
+$primary: rgb(255, 255, 255);
+
+
+// Import base style
+
+@import 'base';
diff --git a/examples/complex/light.scss b/examples/complex/light.scss
new file mode 100644
index 0000000..d71f710
--- /dev/null
+++ b/examples/complex/light.scss
@@ -0,0 +1,1 @@
+@import 'base';
diff --git a/examples/complex/widgets/_qlineedit.scss b/examples/complex/widgets/_qlineedit.scss
new file mode 100644
index 0000000..0462da3
--- /dev/null
+++ b/examples/complex/widgets/_qlineedit.scss
@@ -0,0 +1,6 @@
+QLineEdit {
+    color: $primary;
+    padding: $text-padding;
+    border: 0;
+    border-bottom: 1px solid $primary;
+}
diff --git a/examples/complex/widgets/_qpushbutton.scss b/examples/complex/widgets/_qpushbutton.scss
new file mode 100644
index 0000000..0207748
--- /dev/null
+++ b/examples/complex/widgets/_qpushbutton.scss
@@ -0,0 +1,13 @@
+QPushButton {
+    background: $background;
+    color: $accent;
+    padding: $text-padding;
+    border: 0;
+    border-radius: $border-radius;
+}
+
+QPushButton:hover,
+QPushButton:focus {
+    background: $accent;
+    color: $background;
+}
diff --git a/examples/complex/widgets/_qwidget.scss b/examples/complex/widgets/_qwidget.scss
new file mode 100644
index 0000000..aae4685
--- /dev/null
+++ b/examples/complex/widgets/_qwidget.scss
@@ -0,0 +1,4 @@
+QWidget {
+    background: $background;
+    color: $primary;
+}
diff --git a/qtsass/__init__.py b/qtsass/__init__.py
index f5c2fff..3c14623 100644
--- a/qtsass/__init__.py
+++ b/qtsass/__init__.py
@@ -11,7 +11,7 @@
 from __future__ import absolute_import
 
 # Local imports
-from qtsass.api import compile, compile_and_save
+from qtsass.api import compile, compile_filename, compile_dirname, watch
 
 
 VERSION_INFO = (0, 1, 0)
diff --git a/qtsass/__main__.py b/qtsass/__main__.py
index 1d0dfce..317f82d 100644
--- a/qtsass/__main__.py
+++ b/qtsass/__main__.py
@@ -11,10 +11,11 @@
 
 # Standard library imports
 from __future__ import absolute_import
+import sys
 
 # Local imports
 from qtsass import cli
 
 
 if __name__ == '__main__':
-    cli.main()
+    cli.main(sys.argv[1:])
diff --git a/qtsass/api.py b/qtsass/api.py
index 63e12cb..e224c7b 100644
--- a/qtsass/api.py
+++ b/qtsass/api.py
@@ -15,11 +15,13 @@ import os
 
 # Third party imports
 import sass
+from watchdog.observers import Observer
 
 # Local imports
 from qtsass.conformers import scss_conform, qt_conform
 from qtsass.functions import qlineargradient, rgba
 from qtsass.importers import qss_importer
+from qtsass.events import SourceEventHandler
 
 
 logging.basicConfig(level=logging.DEBUG)
@@ -27,6 +29,8 @@ _log = logging.getLogger(__name__)
 
 
 def compile(input_file):
+    """Compile QtSASS to CSS."""
+
     _log.debug('Compiling {}...'.format(input_file))
 
     with open(input_file, 'r') as f:
@@ -50,11 +54,57 @@ def compile(input_file):
     return ""
 
 
-def compile_and_save(input_file, dest_file):
-    stylesheet = compile(input_file)
-    if dest_file:
-        with open(dest_file, 'w') as css_file:
-            css_file.write(stylesheet)
-            _log.info('Created CSS file {}'.format(dest_file))
+def compile_filename(input_file, dest_file):
+    """Compile QtSASS to CSS and save."""
+
+    css = compile(input_file)
+    with open(dest_file, 'w') as css_file:
+        css_file.write(css)
+        _log.info('Created CSS file {}'.format(dest_file))
+
+
+def compile_dirname(input_dir, output_dir):
+    """Compiles QtSASS files in a directory including subdirectories."""
+
+    def is_valid(file):
+        return not file.startswith('_') and file.endswith('.scss')
+
+    for root, subdirs, files in os.walk(input_dir):
+        relative_root = os.path.relpath(root, input_dir)
+        output_root = os.path.join(output_dir, relative_root)
+
+        for file in [f for f in files if is_valid(f)]:
+            scss_path = os.path.join(root, file)
+            css_file = os.path.splitext(file)[0] + '.css'
+            css_path = os.path.join(output_root, css_file)
+            if not os.path.isdir(output_root):
+                os.makedirs(output_root)
+            compile_filename(scss_path, css_path)
+
+
+def watch(source, destination, compiler=None, recursive=True):
+    """
+    Watches a source file or directory, compiling QtSass files when modified.
+
+    The compiler function defaults to compile_filename when source is a file
+    and compile_dirname when source is a directory.
+
+    :param source: Path to source QtSass file or directory.
+    :param destination: Path to output css file or directory.
+    :param compiler: Compile function (optional)
+    :param recursive: If True, watch subdirectories (default: True).
+    :returns: watchdog.Observer
+    """
+
+    if os.path.isfile(source):
+        watch_dir = os.path.dirname(source)
+        compiler = compiler or compile_filename
     else:
-        print(stylesheet)
+        watch_dir = source
+        compiler = compiler or compile_dirname
+
+    event_handler = SourceEventHandler(source, destination, compiler)
+
+    observer = Observer()
+    observer.schedule(event_handler, watch_dir, recursive=recursive)
+    return observer
diff --git a/qtsass/cli.py b/qtsass/cli.py
index 7d60c62..5cc22c6 100644
--- a/qtsass/cli.py
+++ b/qtsass/cli.py
@@ -13,22 +13,20 @@
 from __future__ import absolute_import, print_function
 import argparse
 import os
+import sys
 import time
 import logging
-
-# Third party imports
-from watchdog.observers import Observer
+import signal
 
 # Local imports
-from qtsass.api import compile_and_save
-from qtsass.events import SourceModificationEventHandler
+from qtsass.api import compile, compile_filename, compile_dirname, watch
 
 
 logging.basicConfig(level=logging.DEBUG)
 _log = logging.getLogger(__name__)
 
 
-def main_parser():
+def create_parser():
     """Create qtsass's cli parser."""
 
     parser = argparse.ArgumentParser(
@@ -51,23 +49,35 @@ def main_parser():
     return parser
 
 
-def main():
+def main(args):
     """qtsass's cli entry point."""
 
-    args = main_parser().parse_args()
-    compile_and_save(args.input, args.output)
+    args = create_parser().parse_args(args)
+    file_mode = os.path.isfile(args.input)
+    dir_mode = os.path.isdir(args.input)
+
+    if file_mode and not args.output:
+        css = compile(args.input)
+        print(css)
+        sys.exit(0)
+
+    elif file_mode:
+        compile_filename(args.input, args.output)
+
+    elif dir_mode and not args.output:
+        print('Error: missing required option: -o/--output')
+        sys.exit(1)
+
+    elif dir_mode:
+        compile_dirname(args.input, args.output)
+
+    else:
+        print('Error: input must be a file or a directory')
+        sys.exit(1)
 
     if args.watch:
-        watched_dir = os.path.abspath(os.path.dirname(args.input))
-        event_handler = SourceModificationEventHandler(
-            args.input,
-            args.output,
-            watched_dir,
-            compile_and_save
-        )
         _log.info('qtsass is watching {}...'.format(args.input))
-        observer = Observer()
-        observer.schedule(event_handler, watched_dir, recursive=False)
+        observer = watch(args.input, args.output)
         observer.start()
         try:
             while True:
@@ -75,3 +85,4 @@ def main():
         except KeyboardInterrupt:
             observer.stop()
         observer.join()
+        sys.exit(0)
diff --git a/qtsass/conformers.py b/qtsass/conformers.py
index eae8a1e..a5d773d 100644
--- a/qtsass/conformers.py
+++ b/qtsass/conformers.py
@@ -6,7 +6,7 @@
 # Licensed under the terms of the MIT License
 # (See LICENSE.txt for details)
 # -----------------------------------------------------------------------------
-"""Conform qss to compliant scss and css to valid qss."""
+"""Conform qss to compliant scss and css to valid qss."""
 
 # Standard library imports
 from __future__ import absolute_import, print_function
@@ -134,4 +134,5 @@ def qt_conform(input_str):
     conformed = input_str
     for conformer in conformers[::-1]:
         conformed = conformer.to_qss(conformed)
+
     return conformed
diff --git a/qtsass/events.py b/qtsass/events.py
index 72ff73f..e10939e 100644
--- a/qtsass/events.py
+++ b/qtsass/events.py
@@ -8,54 +8,17 @@
 # -----------------------------------------------------------------------------
 """Source files event handler."""
 
-# Standard library imports
-import os
-import time
-
 # Third party imports
 from watchdog.events import FileSystemEventHandler
 
 
-# py2 has no FileNotFoundError
-try:
-    FileNotFoundError
-except NameError:
-    FileNotFoundError = IOError
-
-
-class SourceModificationEventHandler(FileSystemEventHandler):
+class SourceEventHandler(FileSystemEventHandler):
 
-    def __init__(self, input_file, dest_file, watched_dir, compiler):
-        super(SourceModificationEventHandler, self).__init__()
-        self._input_file = input_file
-        self._dest_file = dest_file
+    def __init__(self, source, destination, compiler):
+        super(SourceEventHandler, self).__init__()
+        self._source = source
+        self._destination = destination
         self._compiler = compiler
-        self._watched_dir = watched_dir
-        self._watched_extension = os.path.splitext(self._input_file)[1]
-
-    def _recompile(self):
-        i = 0
-        success = False
-        while i < 10 and not success:
-            try:
-                time.sleep(0.2)
-                self._compiler(self._input_file, self._dest_file)
-                success = True
-            except FileNotFoundError:
-                i += 1
 
     def on_modified(self, event):
-        # On Mac, event will always be a directory.
-        # On Windows, only recompile if event's file
-        # has the same extension as the input file
-        we_should_recompile = (
-            event.is_directory and
-            os.path.samefile(event.src_path, self._watched_dir) or
-            os.path.splitext(event.src_path)[1] == self._watched_extension
-        )
-        if we_should_recompile:
-            self._recompile()
-
-    def on_created(self, event):
-        if os.path.splitext(event.src_path)[1] == self._watched_extension:
-            self._recompile()
+        self._compiler(self._source, self._destination)
diff --git a/qtsass/importers.py b/qtsass/importers.py
index 5e8228a..e4bc7a5 100644
--- a/qtsass/importers.py
+++ b/qtsass/importers.py
@@ -16,6 +16,10 @@ import os
 from qtsass.conformers import scss_conform
 
 
+def norm_path(*parts):
+    return os.path.normpath(os.path.join(*parts))
+
+
 def qss_importer(where):
     """
     Returns a function which conforms imported qss files to valid scss to be
@@ -26,17 +30,29 @@ def qss_importer(where):
 
     def find_file(import_file):
 
-        if os.path.isfile(import_file):
-            return import_file
-        extensions = ['.scss', '.css', '.sass']
-        for ext in extensions:
-            potential_file = import_file + ext
-            if os.path.isfile(potential_file):
-                return potential_file
-            potential_file = os.path.join(where, import_file + ext)
+        # Create partial import filename
+        dirname, basename = os.path.split(import_file)
+        if dirname:
+            import_partial_file = '/'.join([dirname, '_' + basename])
+        else:
+            import_partial_file = '_' + basename
+
+        # Build potential file paths for @import "import_file"
+        potential_files = []
+        for ext in ['', '.scss', '.css', '.sass']:
+            full_name = import_file + ext
+            partial_name = import_partial_file + ext
+            potential_files.append(full_name)
+            potential_files.append(partial_name)
+            potential_files.append(norm_path(where, full_name))
+            potential_files.append(norm_path(where, partial_name))
+
+        # Return first existing potential file
+        for potential_file in potential_files:
             if os.path.isfile(potential_file):
                 return potential_file
-        return import_file
+
+        return None
 
     def import_and_conform_file(import_file):
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..28b8e43
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,205 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Copyright (c) 2015 Yann Lanthony
+# Copyright (c) 2017-2018 Spyder Project Contributors
+#
+# Licensed under the terms of the MIT License
+# (See LICENSE.txt for details)
+# -----------------------------------------------------------------------------
+"""Test qtsass cli."""
+
+# Standard library imports
+from __future__ import absolute_import
+import os
+import time
+import signal
+import sys
+from os.path import join, dirname, normpath, exists, basename
+from textwrap import dedent
+from subprocess import Popen, PIPE
+from collections import namedtuple
+
+
+PY3 = sys.version_info.major == 3
+PROJECT_DIR = normpath(dirname(dirname(__file__)))
+Result = namedtuple('Result', "code stdout stderr")
+
+
+def example(*paths):
+    """Get path to an example."""
+
+    return normpath(join(dirname(__file__), '..', 'examples', *paths))
+
+
+def touch(file):
+    """Touch a file."""
+
+    with open(file, 'a') as f:
+        os.utime(file, None)
+
+
+def await_condition(condition, timeout=2000):
+    """Return True if a condition is met in the given timeout period"""
+
+    for _ in range(timeout):
+        if condition():
+            return True
+        time.sleep(0.001)
+    return False
+
+
+def invoke(args):
+    """Invoke qtsass cli with specified args"""
+
+    kwargs = dict(
+        stdout=PIPE,
+        stderr=PIPE,
+        cwd=PROJECT_DIR
+    )
+    proc = Popen(['python', '-m', 'qtsass'] + args, **kwargs)
+    return proc
+
+
+def invoke_with_result(args):
+    """Invoke qtsass cli and return a Result obj"""
+
+    proc = invoke(args)
+    out, err = proc.communicate()
+    out = out.decode('ascii', errors="ignore")
+    err = err.decode('ascii', errors="ignore")
+    return Result(proc.returncode, out, err)
+
+
+def test_compile_dummy_to_stdout():
+    """CLI compile dummy example to stdout."""
+
+    args = [example('dummy.scss')]
+    result = invoke_with_result(args)
+
+    assert result.code == 0
+    assert result.stdout
+
+
+def test_compile_dummy_to_file(tmpdir):
+    """CLI compile dummy example to file."""
+
+    input = example('dummy.scss')
+    output = tmpdir.join('dummy.css')
+    args = [input, '-o', output.strpath]
+    result = invoke_with_result(args)
+
+    assert result.code == 0
+    assert exists(output.strpath)
+
+
+def test_watch_dummy(tmpdir):
+    """CLI watch dummy example."""
+
+    input = example('dummy.scss')
+    output = tmpdir.join('dummy.css')
+    args = [input, '-o', output.strpath, '-w']
+    proc = invoke(args)
+
+    # Wait for initial compile
+    output_exists = lambda: exists(output.strpath)
+    if not await_condition(output_exists):
+        proc.terminate()
+        assert False, "Failed to compile dummy.scss"
+
+    # Ensure subprocess is still alive
+    assert proc.poll() is None
+
+    # Touch input file, triggering a recompile
+    created = output.mtime()
+    file_modified = lambda: output.mtime() > created
+    time.sleep(0.1)
+    touch(input)
+
+    if not await_condition(file_modified):
+        proc.terminate()
+        assert False, 'Output file has not been recompiled...'
+
+    proc.terminate()
+
+
+def test_compile_complex(tmpdir):
+    """CLI compile complex example."""
+
+    input = example('complex')
+    output = tmpdir.mkdir('output')
+    args = [input, '-o', output.strpath]
+    result = invoke_with_result(args)
+
+    assert result.code == 0
+
+    expected_files = [output.join('light.css'), output.join('dark.css')]
+    for file in expected_files:
+        assert exists(file.strpath)
+
+
+def test_watch_complex(tmpdir):
+    """CLI watch complex example."""
+
+    input = example('complex')
+    output = tmpdir.mkdir('output')
+    args = [input, '-o', output.strpath, '-w']
+    proc = invoke(args)
+
+    expected_files = [output.join('light.css'), output.join('dark.css')]
+
+    # Wait for initial compile
+    files_created = lambda: all([exists(f.strpath) for f in expected_files])
+    if not await_condition(files_created):
+        assert False, 'All expected files have not been created...'
+
+    # Ensure subprocess is still alive
+    assert proc.poll() is None
+
+    # Input files to touch
+    input_full = example('complex', 'light.scss')
+    input_partial = example('complex', '_base.scss')
+    input_nested = example('complex', 'widgets', '_qwidget.scss')
+
+    def touch_and_wait(input_file, timeout=2000):
+        """Touch a file, triggering a recompile"""
+
+        filename = basename(input_file)
+        old_mtimes = [f.mtime() for f in expected_files]
+        files_modified = lambda: all(
+            [f.mtime() > old_mtimes[i] for i, f in enumerate(expected_files)]
+        )
+        time.sleep(0.1)
+        touch(input_file)
+
+        if not await_condition(files_modified, timeout):
+            proc.terminate()
+            err = 'Modifying %s did not trigger recompile.' % filename
+            assert False, err
+
+        return True
+
+    assert touch_and_wait(input_full)
+    assert touch_and_wait(input_partial)
+    assert touch_and_wait(input_nested)
+
+    proc.terminate()
+
+
+def test_invalid_input():
+    """CLI input is not a file or dir."""
+
+    proc = invoke_with_result(['file_does_not_exist.scss'])
+    assert proc.code == 1
+    assert 'Error: input must be' in proc.stdout
+
+    proc = invoke_with_result(['./dir/does/not/exist'])
+    assert proc.code == 1
+    assert 'Error: input must be' in proc.stdout
+
+
+def test_dir_missing_output():
+    """CLI dir missing output option"""
+
+    proc = invoke_with_result([example('complex')])
+    assert proc.code == 1
+    assert 'Error: missing required option' in proc.stdout
diff --git a/tests/test_conformers.py b/tests/test_conformers.py
index e77fc4d..22299a0 100644
--- a/tests/test_conformers.py
+++ b/tests/test_conformers.py
@@ -6,7 +6,7 @@
 # Licensed under the terms of the MIT License
 # (See LICENSE.txt for details)
 # -----------------------------------------------------------------------------
-"""Test suite for qtsass."""
+"""Test qtsass conformers."""
 
 # Standard library imports
 from __future__ import absolute_import