Taddeus Kroes před 13 roky
revize
5adff8a5f9
9 změnil soubory, kde provedl 346 přidání a 0 odebrání
  1. 5 0
      .gitignore
  2. 23 0
      Makefile
  3. 62 0
      block.py
  4. 132 0
      csscomp.py
  5. 10 0
      properties.py
  6. 8 0
      tests/run.py
  7. 12 0
      tests/test_base.py
  8. 80 0
      tests/test_block.py
  9. 14 0
      tests/test_properties.py

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+*.pyc
+*.swp
+*~
+coverage
+.coverage

+ 23 - 0
Makefile

@@ -0,0 +1,23 @@
+TEST_OPTIONS := --failfast --catch
+COVERAGE_OPTIONS := --branch
+COVERAGE_DIR := coverage
+
+.PHONY: test coverage pyclean clean
+
+test:
+	@PYTHONDONTWRITEBYTECODE=x python -m unittest discover -s tests \
+		-p 'test_*.py' $(TEST_OPTIONS)
+
+coverage:
+	@python-coverage erase
+	@rm -rf $(COVERAGE_DIR)
+	@PYTHONDONTWRITEBYTECODE=x PYTHONPATH=. python-coverage run --source=. \
+		--omit=tests/* $(COVERAGE_OPTIONS) tests/run.py
+	@python-coverage report
+	@python-coverage html --directory=$(COVERAGE_DIR)
+
+pyclean:
+	find -name \*.pyc -delete
+
+clean: pyclean
+	rm -rf $(COVERAGE_DIR)

+ 62 - 0
block.py

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

+ 132 - 0
csscomp.py

@@ -0,0 +1,132 @@
+#!/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

+ 10 - 0
properties.py

@@ -0,0 +1,10 @@
+def compress_color(properties):
+    return properties
+
+
+def compress_font(properties):
+    return properties
+
+
+def compress_dimension(properties):
+    return properties

+ 8 - 0
tests/run.py

@@ -0,0 +1,8 @@
+import unittest
+
+tests = unittest.defaultTestLoader.discover('tests', 'test_*.py')
+run_options = dict(failfast=True)
+
+if __name__ == '__main__':
+    unittest.TextTestRunner(**run_options).run(tests)
+    print

+ 12 - 0
tests/test_base.py

@@ -0,0 +1,12 @@
+from unittest import TestCase
+
+from csscomp import split_selectors, StyleSheet
+
+
+class TestBase(TestCase):
+    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('\na ,b '), ['a', 'b'])
+        self.assertEqual(split_selectors('a, b\nc'), ['a', 'b c'])
+        self.assertEqual(split_selectors('a,\nb c ,d\n'), ['a', 'b c', 'd'])

+ 80 - 0
tests/test_block.py

@@ -0,0 +1,80 @@
+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'))

+ 14 - 0
tests/test_properties.py

@@ -0,0 +1,14 @@
+from unittest import TestCase
+
+from properties import compress_color, compress_font, compress_dimension
+
+
+class TestProperties(TestCase):
+    def test_compress_color(self):
+        pass
+
+    def test_compress_font(self):
+        pass
+
+    def test_compress_dimension(self):
+        pass