summaryrefslogtreecommitdiff
path: root/scss/tool.py
blob: 53832eb9f84b7c39ebe66e9bd5b48cbd8350f133 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function

from collections import deque
from contextlib import contextmanager
import logging
import os
import re
import sys

from scss import config
from scss.compiler import _prop_split_re
from scss.compiler import Compiler
from scss.errors import SassEvaluationError
from scss.expression import Calculator
from scss.legacy import Scss
from scss.legacy import _default_scss_vars
from scss.namespace import Namespace
from scss.rule import SassRule
from scss.rule import UnparsedBlock
from scss.scss_meta import BUILD_INFO
from scss.source import SourceFile
from scss.util import profiling

try:
    raw_input
except NameError:
    raw_input = input

log = logging.getLogger(__name__)
logging.getLogger('scss').setLevel(logging.INFO)


def main():
    logging.basicConfig(format="%(levelname)s: %(message)s")

    from optparse import OptionGroup, OptionParser, SUPPRESS_HELP

    parser = OptionParser(usage="Usage: %prog [options] [file]",
                          description="Converts Scss files to CSS.",
                          add_help_option=False)
    parser.add_option("-i", "--interactive", action="store_true",
                      help="Run an interactive Scss shell")
    parser.add_option("-w", "--watch", metavar="DIR",
                      help="Watch the files in DIR, and recompile when they change")
    parser.add_option("-r", "--recursive", action="store_true", default=False,
                      help="Also watch directories inside of the watch directory")
    parser.add_option("-o", "--output", metavar="PATH",
                      help="Write output to PATH (a directory if using watch, a file otherwise)")
    parser.add_option("-s", "--suffix", metavar="STRING",
                      help="If using watch, a suffix added to the output filename (i.e. filename.STRING.css)")
    parser.add_option("--time", action="store_true",
                      help="Ignored, will be removed in 2.0")
    parser.add_option("--debug-info", action="store_true",
                      help="Turns on scss's debugging information")
    parser.add_option("--no-debug-info", action="store_false",
                      dest="debug_info", default=False,
                      help="Turns off scss's debugging information")
    parser.add_option("-T", "--test", action="store_true", help=SUPPRESS_HELP)
    parser.add_option("-t", "--style", metavar="NAME",
                      dest="style", default='nested',
                      help="Output style. Can be nested (default), compact, compressed, or expanded.")
    parser.add_option("-C", "--no-compress", action="store_false", dest="style", default=True,
                      help="Don't minify outputted CSS")
    parser.add_option("-?", action="help", help=SUPPRESS_HELP)
    parser.add_option("-h", "--help", action="help",
                      help="Show this message and exit")
    parser.add_option("-v", "--version", action="store_true",
                      help="Print version and exit")

    paths_group = OptionGroup(parser, "Resource Paths")
    paths_group.add_option("-I", "--load-path", metavar="PATH",
                      action="append", dest="load_paths",
                      help="Add a scss import path, may be given multiple times")
    paths_group.add_option("-S", "--static-root", metavar="PATH", dest="static_root",
                      help="Static root path (Where images and static resources are located)")
    paths_group.add_option("-A", "--assets-root", metavar="PATH", dest="assets_root",
                      help="Assets root path (Sprite images will be created here)")
    paths_group.add_option("-a", "--assets-url", metavar="URL", dest="assets_url",
                      help="URL to reach the files in your assets_root")
    paths_group.add_option("-F", "--fonts-root", metavar="PATH", dest="fonts_root",
                      help="Fonts root path (Where fonts are located)")
    paths_group.add_option("-f", "--fonts-url", metavar="PATH", dest="fonts_url",
                      help="URL to reach the fonts in your fonts_root")
    paths_group.add_option("--images-root", metavar="PATH", dest="images_root",
                      help="Images root path (Where images are located)")
    paths_group.add_option("--images-url", metavar="PATH", dest="images_url",
                      help="URL to reach the images in your images_root")
    paths_group.add_option("--cache-root", metavar="PATH", dest="cache_root",
                      help="Cache root path (Cache files will be created here)")
    parser.add_option_group(paths_group)

    parser.add_option("--sass", action="store_true",
                      dest="is_sass", default=None,
                      help="Sass mode")

    options, args = parser.parse_args()

    # General runtime configuration
    if options.static_root is not None:
        config.STATIC_ROOT = options.static_root
    if options.assets_root is not None:
        config.ASSETS_ROOT = options.assets_root

    if options.fonts_root is not None:
        config.FONTS_ROOT = options.fonts_root
    if options.fonts_url is not None:
        config.FONTS_URL = options.fonts_url

    if options.images_root is not None:
        config.IMAGES_ROOT = options.images_root
    if options.images_url is not None:
        config.IMAGES_URL = options.images_url

    if options.cache_root is not None:
        config.CACHE_ROOT = options.cache_root
    if options.load_paths is not None:
        # TODO: Convert global LOAD_PATHS to a list. Use it directly.
        # Doing the above will break backwards compatibility!
        if hasattr(config.LOAD_PATHS, 'split'):
            load_path_list = [p.strip() for p in config.LOAD_PATHS.split(',')]
        else:
            load_path_list = list(config.LOAD_PATHS)

        for path_param in options.load_paths:
            for p in path_param.replace(os.pathsep, ',').replace(';', ',').split(','):
                p = p.strip()
                if p and p not in load_path_list:
                    load_path_list.append(p)

        # TODO: Remove this once global LOAD_PATHS is a list.
        if hasattr(config.LOAD_PATHS, 'split'):
            config.LOAD_PATHS = ','.join(load_path_list)
        else:
            config.LOAD_PATHS = load_path_list
    if options.assets_url is not None:
        config.ASSETS_URL = options.assets_url

    # Execution modes
    if options.test:
        run_tests()
    elif options.version:
        print_version()
    elif options.interactive:
        run_repl(options)
    elif options.watch:
        watch_sources(options)
    else:
        do_build(options, args)


def print_version():
    print(BUILD_INFO)


def run_tests():
    try:
        import pytest
    except ImportError:
        raise ImportError("You need py.test installed to run the test suite.")
    pytest.main("")  # don't let py.test re-consume our arguments


def do_build(options, args):
    if options.output is not None:
        out = open(options.output, 'wb')
    else:
        out = sys.stdout
        # Get the unencoded stream on Python 3
        out = getattr(out, 'buffer', out)

    css = Scss(scss_opts={
        'style': options.style,
        'debug_info': options.debug_info,
    })
    if not args:
        args = ['-']
    source_files = []
    for path in args:
        if path == '-':
            source = SourceFile.from_file(sys.stdin, "<stdin>", is_sass=options.is_sass)
        else:
            source = SourceFile.from_filename(path, is_sass=options.is_sass)
        source_files.append(source)

    encodings = set(source.encoding for source in source_files)
    if len(encodings) > 1:
        sys.stderr.write(
            "Can't combine these files!  "
            "They have different encodings: {0}\n"
            .format(', '.join(encodings))
        )
        sys.exit(3)

    output = css.compile(source_files=source_files)
    out.write(output.encode(source_files[0].encoding))

    for f, t in profiling.items():
        sys.stderr.write("%s took %03fs" % (f, t))


def watch_sources(options):
    import time
    try:
        from watchdog.observers import Observer
        from watchdog.events import PatternMatchingEventHandler
    except ImportError:
        sys.stderr.write("Using watch functionality requires the `watchdog` library: http://pypi.python.org/pypi/watchdog/")
        sys.exit(1)
    if options.output and not os.path.isdir(options.output):
        sys.stderr.write("watch file output directory is invalid: '%s'" % (options.output))
        sys.exit(2)

    class ScssEventHandler(PatternMatchingEventHandler):
        def __init__(self, *args, **kwargs):
            super(ScssEventHandler, self).__init__(*args, **kwargs)
            self.css = Scss(scss_opts={
                'style': options.style,
                'debug_info': options.debug_info,
            })
            self.output = options.output
            self.suffix = options.suffix

        def is_valid(self, path):
            return os.path.isfile(path) and (path.endswith('.scss') or path.endswith('.sass')) and not os.path.basename(path).startswith('_')

        def process(self, path):
            if os.path.isdir(path):
                for f in os.listdir(path):
                    full = os.path.join(path, f)
                    if self.is_valid(full):
                        self.compile(full)
            elif self.is_valid(path):
                self.compile(path)

        def compile(self, src_path):
            fname = os.path.basename(src_path)
            if fname.endswith('.scss') or fname.endswith('.sass'):
                fname = fname[:-5]
                if self.suffix:
                    fname += '.' + self.suffix
                fname += '.css'
            else:
                # you didn't give me a file of the correct type!
                return False

            if self.output:
                dest_path = os.path.join(self.output, fname)
            else:
                dest_path = os.path.join(os.path.dirname(src_path), fname)

            print("Compiling %s => %s" % (src_path, dest_path))
            dest_file = open(dest_path, 'w')
            dest_file.write(self.css.compile(scss_file=src_path))

        def on_moved(self, event):
            super(ScssEventHandler, self).on_moved(event)
            self.process(event.dest_path)

        def on_created(self, event):
            super(ScssEventHandler, self).on_created(event)
            self.process(event.src_path)

        def on_modified(self, event):
            super(ScssEventHandler, self).on_modified(event)
            self.process(event.src_path)

    event_handler = ScssEventHandler(patterns=['*.scss', '*.sass'])
    observer = Observer()
    observer.schedule(event_handler, path=options.watch, recursive=options.recursive)
    observer.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()


@contextmanager
def readline_history(fn):
    try:
        import readline
    except ImportError:
        yield
        return

    try:
        readline.read_history_file(fn)
    except IOError:
        pass

    try:
        yield
    finally:
        try:
            readline.write_history_file(fn)
        except IOError:
            pass


def run_repl(is_sass=False):
    repl = SassRepl()

    with readline_history(os.path.expanduser('~/.scss-history')):
        print("Welcome to %s interactive shell" % (BUILD_INFO,))
        while True:
            try:
                in_ = raw_input('>>> ').strip()
                for output in repl(in_):
                    print(output)
            except (EOFError, KeyboardInterrupt):
                print("Bye!")
                return


class SassRepl(object):
    def __init__(self, is_sass=False):
        # TODO it would be lovely to get these out of here, somehow
        self.namespace = Namespace(variables=_default_scss_vars)

        self.compiler = Compiler(namespace=self.namespace)
        self.compilation = self.compiler.make_compilation()
        self.legacy_compiler_options = {}
        self.source_file = SourceFile.from_string('', '<shell>', is_sass=is_sass)
        self.calculator = Calculator(self.namespace)

    def __call__(self, s):
        # TODO this is kind of invasive; surely it's possible to do this
        # without calling only private methods
        from pprint import pformat

        if s in ('exit', 'quit'):
            raise KeyboardInterrupt

        for s in s.split(';'):
            s = self.source_file.prepare_source(s.strip())
            if not s:
                continue
            elif s.startswith('@'):
                scope = None
                properties = []
                children = deque()
                rule = SassRule(self.source_file, namespace=self.namespace, legacy_compiler_options=self.legacy_compiler_options, properties=properties)
                block = UnparsedBlock(rule, 1, s, None)
                code, name = (s.split(None, 1) + [''])[:2]
                if code == '@option':
                    self.compilation._at_options(self.calculator, rule, scope, block)
                    continue
                elif code == '@import':
                    self.compilation._at_import(self.calculator, rule, scope, block)
                    continue
                elif code == '@include':
                    final_cont = ''
                    self.compilation._at_include(self.calculator, rule, scope, block)
                    code = self.compilation._print_properties(properties).rstrip('\n')
                    if code:
                        final_cont += code
                    if children:
                        self.compilation.children.extendleft(children)
                        self.compilation.parse_children()
                        code = self.compilation._create_css(self.compilation.rules).rstrip('\n')
                        if code:
                            final_cont += code
                    yield final_cont
                    continue
            elif s == 'ls' or s.startswith('show(') or s.startswith('show ') or s.startswith('ls(') or s.startswith('ls '):
                m = re.match(r'(?:show|ls)(\()?\s*([^,/\\) ]*)(?:[,/\\ ]([^,/\\ )]+))*(?(1)\))', s, re.IGNORECASE)
                if m:
                    name = m.group(2)
                    code = m.group(3)
                    name = name and name.strip().rstrip('s')  # remove last 's' as in functions
                    code = code and code.strip()
                    ns = self.namespace
                    if not name:
                        yield pformat(list(sorted(['vars', 'options', 'mixins', 'functions'])))
                    elif name in ('v', 'var', 'variable'):
                        variables = dict(ns._variables)
                        if code == '*':
                            pass
                        elif code:
                            variables = dict((k, v) for k, v in variables.items() if code in k)
                        else:
                            variables = dict((k, v) for k, v in variables.items() if not k.startswith('$--'))
                        yield pformat(variables)

                    elif name in ('o', 'opt', 'option'):
                        opts = self.legacy_compiler_options
                        if code == '*':
                            pass
                        elif code:
                            opts = dict((k, v) for k, v in opts.items() if code in k)
                        else:
                            opts = dict((k, v) for k, v in opts.items())
                        yield pformat(opts)

                    elif name in ('m', 'mix', 'mixin', 'f', 'func', 'funct', 'function'):
                        if name.startswith('m'):
                            funcs = dict(ns._mixins)
                        elif name.startswith('f'):
                            funcs = dict(ns._functions)
                        if code == '*':
                            pass
                        elif code:
                            funcs = dict((k, v) for k, v in funcs.items() if code in k[0])
                        else:
                            pass
                        # TODO print source when possible
                        yield pformat(funcs)
                    continue
            elif s.startswith('$') and (':' in s or '=' in s):
                prop, value = [a.strip() for a in _prop_split_re.split(s, 1)]
                prop = self.calculator.do_glob_math(prop)
                value = self.calculator.calculate(value)
                self.namespace.set_variable(prop, value)
                continue

            # TODO respect compress?
            try:
                yield(self.calculator.calculate(s).render())
            except (SyntaxError, SassEvaluationError) as e:
                print("%s" % e, file=sys.stderr)


if __name__ == "__main__":
    main()