Browse Source

Reordered file structure and added support for media queries in the parser

Taddeus Kroes 13 years ago
parent
commit
8912c7b183
10 changed files with 326 additions and 277 deletions
  1. 0 62
      block.py
  2. 7 0
      compress.py
  3. 83 0
      csscom.py
  4. 0 132
      csscomp.py
  5. 94 0
      generate.py
  6. 78 0
      parse.py
  7. 0 80
      tests/test_block.py
  8. 5 1
      tests/test_compress.py
  9. 57 0
      tests/test_generate.py
  10. 2 2
      tests/test_parse.py

+ 0 - 62
block.py

@@ -1,62 +0,0 @@
-import properties as props
-
-
-class Block(object):
-    """
-    A Block is a stylesheet block of the following form:
-
-    <selector>,
-    ... {
-        <property>: <value>;
-        ...
-    }
-    """
-    def __init__(self, *args):
-        self.selectors = sorted(set(args))
-        self.properties = set()
-
-    def add_property(self, name, value):
-        self.properties.add((name, value))
-
-    def generate_css(self, compress_whitespace=False, compress_color=False,
-                     compress_font=False, compress_dimension=False):
-        if compress_whitespace:
-            comma = ','
-            colon = ':'
-            newline = ''
-            lbracket = '{'
-            rbracket = '}'
-        else:
-            comma = ',\n'
-            colon = ': '
-            newline = '\n\t'
-            lbracket = ' {'
-            rbracket = '\n}'
-
-        properties = self.properties
-
-        if compress_color:
-            properties = props.compress_color(properties)
-
-        if compress_font:
-            properties = props.compress_font(properties)
-
-        if compress_dimension:
-            properties = props.compress_dimension(properties)
-
-        selector = comma.join(self.selectors)
-        properties = [newline + name + colon + value
-                      for name, value in self.properties]
-        inner = ';'.join(properties)
-
-        if len(properties) and not compress_whitespace:
-            inner += ';'
-
-        return selector + lbracket + inner + rbracket
-
-    def __repr__(self):  # pragma: nocover
-        return '<Block "%s" properties=%d>' \
-               % (', '.join(self.selectors), len(self.properties))
-
-    def __str__(self):
-        return self.generate_css()

+ 7 - 0
properties.py → compress.py

@@ -1,3 +1,10 @@
+def compress_blocks(blocks):
+    # Map of property stringification to list of containing blocks
+    #property_locations = {}
+
+    return blocks
+
+
 def compress_color(properties):
 def compress_color(properties):
     return properties
     return properties
 
 

+ 83 - 0
csscom.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python
+from argparse import ArgumentParser
+
+from parse import parse_groups
+from generate import generate_group
+
+
+def compress_css(css, compress_blocks=True, compress_whitespace=True,
+                 compress_color=True, compress_font=True,
+                 compress_dimension=True, sort_properties=True):
+    groups = parse_groups(css)
+    options = dict(compress_blocks=compress_blocks,
+                   compress_whitespace=compress_whitespace,
+                   compress_color=compress_color,
+                   compress_font=compress_font,
+                   compress_dimension=compress_dimension,
+                   sort_properties=sort_properties)
+    compressed_groups = [generate_group(selectors, blocks, **options)
+                         for selectors, blocks in groups]
+    newlines = '' if compress_whitespace else '\n\n'
+    return newlines.join(compressed_groups)
+
+
+def parse_options():
+    parser = ArgumentParser(description='Just another CSS compressor.')
+    parser.add_argument('files', metavar='FILE', nargs='+',
+                        help='list of CSS files to compress')
+    parser.add_argument('-cw', '--compress-whitespace', action='store_true',
+                        help='omit unnecessary whitespaces and semicolons')
+    parser.add_argument('-cc', '--compress-color', action='store_true',
+                        help='replace color codes/names with shorter synonyms')
+    parser.add_argument('-cf', '--compress-font', action='store_true',
+                        help='replace separate font statements with shortcut '
+                             'font statement where possible')
+    parser.add_argument('-cd', '--compress-dimension', action='store_true',
+                        help='replace separate margin/padding statements with '
+                             'shortcut statements where possible')
+    parser.add_argument('-cb', '--compress-blocks', action='store_true',
+                        help='combine or split blocks into blocks with '
+                             'comma-separated selectors if it results in less '
+                             'css code')
+    parser.add_argument('-nc', '--no-compression', action='store_true',
+                        help='don\'t apply any compression, just generate CSS')
+    parser.add_argument('-ns', '--no-sort', action='store_false',
+                        dest='sort_properties', help='sort property names')
+    parser.add_argument('-o', '--output', help='filename for compressed '
+                                               'output (default is stdout)')
+    args = parser.parse_args()
+
+    # Enable all compression options if none are explicitely enabled
+    if not any([args.compress_whitespace, args.compress_color,
+                args.compress_font, args.compress_dimension,
+                args.compress_blocks]) and not args.no_compression:
+        args.compress_whitespace = args.compress_color = args.compress_font = \
+                args.compress_dimension = args.compress_blocks = True
+
+    return args
+
+
+def _content(filename):
+    handle = open(filename, 'r')
+    content = '\n' + handle.read()
+    handle.close()
+    return content
+
+
+if __name__ == '__main__':  # pragma: nocover
+    args = parse_options()
+    options = dict(args._get_kwargs())
+    files = options.pop('files')
+    output_file = options.pop('output')
+    del options['no_compression']
+
+    try:
+        css = '\n'.join(_content(filename) for filename in files)
+        compressed = compress_css(css, **options)
+
+        if output_file:
+            open(output_file, 'w').write(compressed)
+        else:
+            print compressed,
+    except IOError as e:
+        print e

+ 0 - 132
csscomp.py

@@ -1,132 +0,0 @@
-#!/usr/bin/env python
-from block import Block
-
-
-def split_selectors(raw_selector):
-    """
-    Split a selector with commas and arbitrary whitespaces into a list of
-    selector swith single-space whitespaces.
-    """
-    return [' '.join(s.split()) for s in raw_selector.split(',')]
-
-
-class StyleSheet(object):
-    def __init__(self, css=None):
-        # List of encountered blocks
-        self.blocks = []
-
-        # Map of property stringification to list of containing blocks
-        self.property_locations = {}
-
-        if css:
-            self.parse_css(css)
-
-    def parse_css(self, css):
-        """
-        Parse CSS code one character at a time. This is more efficient than
-        simply splitting on brackets, especially for large style sheets. All
-        comments are ignored (both inline and multiline).
-        """
-        stack = char = ''
-        prev = block = None
-        lineno = 1
-        multiline_comment = inline_comment = False
-
-        try:
-            for c in css:
-                char = c
-
-                if multiline_comment:
-                    # Multiline comment end?
-                    if c == '/' and prev == '*':
-                        multiline_comment = False
-                elif inline_comment:
-                    # Inline comment end?
-                    if c == '\n':
-                        inline_comment = False
-                elif c == '{':
-                    # Block start
-                    selectors = split_selectors(stack)
-                    stack = ''
-                    assert len(selectors)
-                    block = Block(*selectors)
-                elif c == '}':
-                    # Block end
-                    assert block is not None
-                    block = None
-                elif c == ';':
-                    # Property definition
-                    assert block is not None
-                    name, value = map(str.strip, stack.split(':', 1))
-                    assert '\n' not in name
-                    block.add_property(name, value)
-                elif c == '*' and prev == '/':
-                    # Multiline comment start
-                    multiline_comment = True
-                elif c == '/' and prev == '/':
-                    # Inline comment start
-                    inline_comment = True
-                else:
-                    if c == '\n':
-                        lineno += 1
-
-                    stack += c
-                    prev = c
-        except AssertionError:
-            raise Exception('unexpected \'%c\' on line %d' % (char, lineno))
-
-    def generate_css(self, compress_whitespace=False, compress_color=False,
-                     compress_font=False, compress_dimension=False,
-                     compress_blocks=False):
-        """
-        Generate CSS code for the entire stylesheet.
-
-        Options:
-        compress_whitespace | Omit unnecessary whitespaces and semicolons.
-        compress_color      | Replace color codes/names with shorter synonyms.
-        compress_font       | Replace separate font statements with shortcut
-                            | font statement where possible.
-        compress_dimension  | Replace separate margin/padding statements with
-                            | shortcut statements where possible.
-        compress_blocks     | Combine or split blocks into blocks with
-                            | comma-separated selectors if it results in less
-                            | CSS code.
-        """
-        blocks = self.blocks
-
-        if compress_blocks:
-            blocks = self._compress_blocks()
-
-        options = dict(compress_whitespace=compress_whitespace,
-                       compress_color=compress_color,
-                       compress_font=compress_font,
-                       compress_dimension=compress_dimension)
-        newline = '' if compress_whitespace else '\n'
-
-        return newline.join(block.generate_css(**options)
-                            for block in self.blocks)
-
-    def _compress_blocks(self):
-        pass
-        #for block in self.blocks
-
-    def compress(self, **kwargs):
-        """
-        Shortcut for `generate_css`, with all compression options enabled by
-        default. Keyword argument names are preceded by 'compress_' before
-        being passed to `generate_css`.
-        """
-        options = dict(whitespace=True, color=True, font=True, dimension=True,
-                       blocks=True)
-        options.update(kwargs)
-        options = dict([('compress_' + k, v) for k, v in options.items()])
-
-        return self.generate_css(**options)
-
-    def __str__(self):
-        return self.generate_css()
-
-
-if __name__ == '__main__':  # pragma: nocover
-    # TODO: Command-line options parser
-    pass

+ 94 - 0
generate.py

@@ -0,0 +1,94 @@
+from operator import itemgetter
+
+import compress
+
+
+def _indent(text, tab='\t'):
+    indented = ''
+
+    for i, line in enumerate(text.split('\n')):
+        if i:
+            indented += '\n'
+
+        if len(line):
+            indented += tab
+
+        indented += line
+
+    return indented
+
+
+def _indented_block(selectors, inner, compress_whitespace=False, tab='\t'):
+    if compress_whitespace:
+        comma = ','
+        lbracket = '{'
+        rbracket = '}'
+    else:
+        # TODO: use this?: comma = ',\n'
+        comma = ', '
+        lbracket = ' {'
+        rbracket = '\n}'
+        inner = _indent(inner, tab=tab)
+
+    selector = comma.join(sorted(set(selectors)))
+    return selector + lbracket + inner + rbracket
+
+
+def generate_block(selectors, properties, compress_whitespace=False,
+                   sort_properties=False, tab='\t'):
+    """
+    Generate CSS code for a single block.
+    """
+    if sort_properties:
+        properties.sort(key=itemgetter(0))
+
+    if compress_whitespace:
+        newline = ''
+        colon = ':'
+    else:
+        newline = '\n'
+        colon = ': '
+
+    properties = [newline + name + colon + value
+                  for name, value in properties]
+    inner = ';'.join(properties)
+
+    if not compress_whitespace:
+        inner += ';'
+
+    return _indented_block(selectors, inner,
+                           compress_whitespace=compress_whitespace, tab=tab)
+
+
+def generate_group(selectors, blocks, compress_blocks=True,
+                   compress_whitespace=True, compress_color=True,
+                   compress_font=True, compress_dimension=True,
+                   sort_properties=True, tab='\t'):
+    compressed_blocks = []
+
+    if compress_blocks:
+        blocks = compress.compress_blocks(blocks)
+
+    for block_selectors, properties in blocks:
+        if compress_color:
+            properties = compress.compress_color(properties)
+
+        if compress_font:
+            properties = compress.compress_font(properties)
+
+        if compress_dimension:
+            properties = compress.compress_dimension(properties)
+
+        compressed_blocks.append(generate_block(block_selectors, properties,
+            compress_whitespace=compress_whitespace,
+            sort_properties=sort_properties, tab=tab))
+
+    newline = '' if compress_whitespace else '\n'
+    inner = newline.join(compressed_blocks)
+
+    if selectors is None:
+        # Root-level group
+        return inner
+
+    return _indented_block(selectors, newline + inner,
+                           compress_whitespace=compress_whitespace, tab=tab)

+ 78 - 0
parse.py

@@ -0,0 +1,78 @@
+def split_selectors(raw_selector):
+    """
+    Split a selector with commas and arbitrary whitespaces into a list of
+    selector swith single-space whitespaces.
+    """
+    return [' '.join(s.split()) for s in raw_selector.split(',')]
+
+
+def parse_groups(css):
+    """
+    Parse CSS code one character at a time. This is more efficient than
+    simply splitting on brackets, especially for large style sheets. All
+    comments are ignored (both inline and multiline).
+    """
+    stack = char = ''
+    prev_char = None
+    lineno = 1
+    properties = []
+    current_group = root_group = []
+    groups = [(None, root_group)]
+    selectors = None
+    multiline_comment = inline_comment = False
+
+    try:
+        for c in css:
+            char = c
+
+            if multiline_comment:
+                # Multiline comment end?
+                if c == '/' and prev_char == '*':
+                    multiline_comment = False
+            elif inline_comment:
+                # Inline comment end?
+                if c == '\n':
+                    inline_comment = False
+            elif c == '{':
+                # Block start
+                if selectors is not None:
+                    # Block is nested, save group selector
+                    current_group = []
+                    groups.append((selectors, current_group))
+
+                selectors = split_selectors(stack)
+                #print stack.strip(), '->', selectors
+                stack = ''
+                assert len(selectors)
+            elif c == '}':
+                if selectors is None:
+                    # Closing group
+                    current_group = root_group
+                else:
+                    # Closing block
+                    current_group.append((selectors, properties))
+                    selectors = None
+                    properties = []
+            elif c == ';':
+                # Property definition
+                assert selectors is not None
+                name, value = map(str.strip, stack.split(':', 1))
+                assert '\n' not in name
+                properties.append((name, value))
+                stack = ''
+            elif c == '*' and prev_char == '/':
+                # Multiline comment start
+                multiline_comment = True
+            elif c == '/' and prev_char == '/':
+                # Inline comment start
+                inline_comment = True
+            else:
+                if c == '\n':
+                    lineno += 1
+
+                stack += c
+                prev_char = c
+    except AssertionError:
+        raise Exception('unexpected \'%c\' on line %d' % (char, lineno))
+
+    return groups

+ 0 - 80
tests/test_block.py

@@ -1,80 +0,0 @@
-from unittest import TestCase
-
-from block import Block
-
-
-class TestBlock(TestCase):
-    def test_constructor(self):
-        s = '#foo div'
-        b = Block(s)
-        self.assertEqual(b.selectors, [s])
-        self.assertEqual(b.properties, set())
-
-
-class TestBlockAddProperty(TestCase):
-    def setUp(self):
-        self.b = Block('foo')
-
-    def test_single_value(self):
-        self.b.add_property('foo', 'bar')
-        self.assertEqual(self.b.properties, set([('foo', 'bar')]))
-
-    def test_double_value(self):
-        self.b.add_property('foo', 'bar')
-        self.b.add_property('foo', 'bar')
-        self.assertEqual(self.b.properties, set([('foo', 'bar')]))
-
-    def test_multiple_values(self):
-        self.b.add_property('foo', 'bar')
-        self.b.add_property('foo', 'baz')
-        self.assertEqual(self.b.properties, set([('foo', 'bar'),
-                                                 ('foo', 'baz')]))
-
-
-class TestBlockGenerateCss(TestCase):
-    def setUp(self):
-        self.div = Block('div')
-        self.div.add_property('color', '#000')
-        self.div.add_property('font-weight', 'bold')
-
-    def test_nocompress_empty(self):
-        div = Block('div')
-        self.assertEqual(div.generate_css(), 'div {\n}')
-
-    def test_nocompress_single_property(self):
-        div = Block('div')
-        div.add_property('color', '#000')
-        self.assertEqualCss(div.generate_css(), '''
-div {
-    color: #000;
-}''')
-
-    def test_nocompress_multiple_properties(self):
-        self.assertEqualCss(self.div.generate_css(), '''
-div {
-    color: #000;
-    font-weight: bold;
-}''')
-
-    def test_nocompress_multiple_selectors(self):
-        div = Block('div', 'p')
-        div.add_property('color', '#000')
-        self.assertEqualCss(div.generate_css(), '''
-div,
-p {
-    color: #000;
-}''')
-
-    def test_nocompress_multiple_selectors_sorted(self):
-        self.assertMultiLineEqual(Block('div', 'p').generate_css(),
-                                  Block('p', 'div').generate_css())
-
-    def test_compress_whitespace(self):
-        self.assertEqual(self.div.generate_css(compress_whitespace=True),
-                         'div{color:#000;font-weight:bold}')
-
-    def test___str__(self):
-        self.assertEqual(str(self.div), self.div.generate_css())
-
-    def assertEqualCss(self, a, b):
-        self.assertMultiLineEqual(a, b.strip().replace('    ', '\t'))

+ 5 - 1
tests/test_properties.py → tests/test_compress.py

@@ -1,9 +1,13 @@
 from unittest import TestCase
 from unittest import TestCase
 
 
-from properties import compress_color, compress_font, compress_dimension
+from compress import compress_blocks, compress_color, compress_font, \
+        compress_dimension
 
 
 
 
 class TestProperties(TestCase):
 class TestProperties(TestCase):
+    def test_compress_blocks(self):
+        pass
+
     def test_compress_color(self):
     def test_compress_color(self):
         pass
         pass
 
 

+ 57 - 0
tests/test_generate.py

@@ -0,0 +1,57 @@
+from unittest import TestCase
+
+from generate import generate_block, generate_group
+
+
+class TestGenerateBlock(TestCase):
+    def setUp(self):
+        self.selector = 'div'
+        self.properties = [('color', '#000'), ('font-weight', 'bold')]
+
+    def test_nocompress_empty(self):
+        self.assertGenerates(['div'], [], 'div {\n}')
+
+    def test_nocompress_single_property(self):
+        selectors = ['div']
+        properties = [('color', '#000')]
+        self.assertGenerates(selectors, properties, '''
+div {
+    color: #000;
+}''')
+
+    def test_nocompress_multiple_properties(self):
+        selectors = ['div']
+        properties = [('color', '#000'), ('font-weight', 'bold')]
+        self.assertGenerates(selectors, properties, '''
+div {
+    color: #000;
+    font-weight: bold;
+}''')
+
+    def test_nocompress_multiple_selectors(self):
+        selectors = ['div', 'p']
+        properties = [('color', '#000')]
+        self.assertGenerates(selectors, properties, '''
+div,
+p {
+    color: #000;
+}''')
+
+    def test_nocompress_multiple_selectors_sorted(self):
+        self.assertMultiLineEqual(generate_block(['div', 'p'], []),
+                                  generate_block(['p', 'div'], []))
+
+    def test_compress_whitespace(self):
+        selectors = ['div']
+        properties = [('color', '#000'), ('font-weight', 'bold')]
+        self.assertGenerates(selectors, properties,
+                             'div{color:#000;font-weight:bold}',
+                             compress_whitespace=True)
+
+    def assertGenerates(self, selectors, props, css, **kwargs):
+        self.assertMultiLineEqual(generate_block(selectors, props, **kwargs),
+                                  css.strip().replace('    ', '\t'))
+
+
+class TestGenerateGroup(TestCase):
+    pass

+ 2 - 2
tests/test_base.py → tests/test_parse.py

@@ -1,9 +1,9 @@
 from unittest import TestCase
 from unittest import TestCase
 
 
-from csscomp import split_selectors, StyleSheet
+from parse import split_selectors, parse_groups
 
 
 
 
-class TestBase(TestCase):
+class TestParse(TestCase):
     def test_split_selectors(self):
     def test_split_selectors(self):
         self.assertEqual(split_selectors('a, b'), ['a', 'b'])
         self.assertEqual(split_selectors('a, b'), ['a', 'b'])
         self.assertEqual(split_selectors('a ,b'), ['a', 'b'])
         self.assertEqual(split_selectors('a ,b'), ['a', 'b'])