Skip to content

Commit

Permalink
Merge 4447cc9 into 6bd2ae2
Browse files Browse the repository at this point in the history
  • Loading branch information
jnothman committed May 10, 2017
2 parents 6bd2ae2 + 4447cc9 commit 839b1e0
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 45 deletions.
115 changes: 101 additions & 14 deletions cssdecl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

import re
import warnings
from collections import defaultdict

try:
import tinycss2
import tinycss2.color3
except ImportError:
# currently needed for setup.cfg to get version :(
pass

__version__ = '0.1.3+dev'

Expand All @@ -16,6 +24,63 @@ class CSSWarning(UserWarning):
pass


def _clean_tokens(tokens):
cleaned = []
for tok in tokens:
if tok.type == 'comment' or tok.type == 'whitespace':
pass
elif tok.type == 'error':
# TODO: indicate error context (requires
warnings.warn('Error parsing CSS: %r' % tok.message,
CSSWarning)
else:
cleaned.append(tok)
return cleaned


def match_color_token(token):
return tinycss2.color3.parse_color(token) is not None


def match_size_token(token):
return (token.type == 'dimension' or token.type == 'percentage'
or (token.type == 'ident' and token.lower_value in {
'medium', 'thin', 'thick', 'smaller', 'larger', 'xx-small',
'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'}))


class IdentMatch:
def __init__(self, values):
assert not isinstance(values, str)
self.values = set(values)

def __call__(self, token):
return token.type == 'ident' and token.lower_value in self.values


match_inherit_initial = IdentMatch(['inherit', 'initial'])


def match_tokens(tokens, matchers, remainder):
cleaned = _clean_tokens(tokens)
if len(cleaned) == 1 and match_inherit_initial(cleaned[0]):
out = {remainder: cleaned}
for k in matchers:
out[k] = cleaned
return out

out = defaultdict(list)
for tok in cleaned:
for k, matcher in matchers.items():
if matcher(tok):
out[k].append(tok)
break
else:
out[remainder].append(tok)
out.default_factory = None
return out


class _BaseCSSResolver(object):
"""Base class for parsing and resolving CSS to atomic properties
Expand Down Expand Up @@ -212,18 +277,11 @@ def _parse(self, declarations_str):
In a future version may generate parsed tokens from tinycss/tinycss2
"""
for decl in declarations_str.split(';'):
if not decl.strip():
continue
prop, sep, val = decl.partition(':')
prop = prop.strip().lower()
# TODO: don't lowercase case sensitive parts of values (strings)
val = val.strip().lower()
if sep:
yield prop, val
else:
warnings.warn('Ill-formatted attribute: expected a colon '
'in %r' % decl, CSSWarning)
decls = tinycss2.parse_declaration_list(declarations_str)
decls = _clean_tokens(decls)
for decl in decls:
value_str = tinycss2.serialize(decl.value).strip().lower()
yield decl.lower_name, value_str


class _CommonExpansions(object):
Expand All @@ -237,15 +295,15 @@ class _CommonExpansions(object):

def _side_expander(prop_fmt):
def expand(self, prop, value):
tokens = value.split()
tokens = _clean_tokens(tinycss2.parse_component_value_list(value))
try:
mapping = self.SIDE_SHORTHANDS[len(tokens)]
except KeyError:
warnings.warn('Could not expand "%s: %s"' % (prop, value),
CSSWarning)
return
for key, idx in zip(self.SIDES, mapping):
yield prop_fmt % key, tokens[idx]
yield prop_fmt % key, tinycss2.serialize(tokens[idx:idx + 1])

return expand

Expand All @@ -255,6 +313,35 @@ def expand(self, prop, value):
expand_margin = _side_expander('margin-%s')
expand_padding = _side_expander('padding-%s')

def expand_border(self, prop, value, side=None):
if side is None:
sides = ['top', 'right', 'bottom', 'left']
else:
sides = [side]

# TODO: clean tokens?
value = tinycss2.parse_component_value_list(value)
matched = match_tokens(value,
{'width': match_size_token,
'color': match_color_token},
remainder='style')
for side in sides:
for k, v in matched.items():
if v:
v = _clean_tokens(v)
yield 'border-%s-%s' % (side, k), tinycss2.serialize(v)

def _border_side_expander(side):
# XXX: functools.partial cannot be bound!
def expand_border_side(self, prop, value):
return self.expand_border(prop, value, side)
return expand_border_side

expand_border_top = _border_side_expander('top')
expand_border_right = _border_side_expander('right')
expand_border_bottom = _border_side_expander('bottom')
expand_border_left = _border_side_expander('left')


class CSS22Resolver(_BaseCSSResolver, _CommonExpansions):
"""Parses and resolves CSS to atomic CSS 2.2 properties
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def setup_package():
# See setup.cfg
setup(py_modules=['cssdecl'],
setup_requires=['pytest-runner'],
tests_require=['pytest>=2.7', 'pytest-cov~=2.4'])
tests_require=['pytest>=2.7', 'pytest-cov~=2.4'],
install_requires=['tinycss2~=0.5'])
finally:
del sys.path[0]
os.chdir(old_path)
Expand Down
104 changes: 74 additions & 30 deletions test_cssdecl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
from cssdecl import CSS22Resolver, CSSWarning


# TODO: should add generic variants of tests, e.g. with hypothesis
# to test for comment intrusion, alternative whitespace, etc.


def assert_resolves(css, props, inherited=None):
resolver = CSS22Resolver()
actual = resolver.resolve_string(css, inherited=inherited)
Expand Down Expand Up @@ -34,9 +38,6 @@ def test_css_parse_comments():
'hello/* foo */:/* bar \n */ world /*;not:here*/')


@pytest.mark.xfail(reason='''we don't need to handle specificity
markers like !important, but we should
ignore them in the future''')
def test_css_parse_specificity():
assert_same_resolution('font-weight: bold', 'font-weight: bold !important')

Expand Down Expand Up @@ -83,6 +84,19 @@ def test_css_parse_invalid(invalid_css, remainder):
# TODO: we should be checking that in other cases no warnings are raised


@pytest.mark.parametrize('style', [
'margin-left:1pt',
'border-top-width:1pt',
'border-top-style:solid',
'color:red',
'font-size:1pt',
# TODO: fill out
])
def test_normal(style):
# TODO: also test normal pairs
assert_resolves(style, dict([style.split(':', 1)]))


@pytest.mark.parametrize(
'shorthand,expansions',
[('margin', ['margin-top', 'margin-right',
Expand All @@ -96,24 +110,40 @@ def test_css_parse_invalid(invalid_css, remainder):
('border-style', ['border-top-style', 'border-right-style',
'border-bottom-style', 'border-left-style']),
])
def test_css_side_shorthands(shorthand, expansions):
top, right, bottom, left = expansions

assert_resolves('%s: 1pt' % shorthand,
{top: '1pt', right: '1pt',
bottom: '1pt', left: '1pt'})

assert_resolves('%s: 1pt 4pt' % shorthand,
{top: '1pt', right: '4pt',
bottom: '1pt', left: '4pt'})

assert_resolves('%s: 1pt 4pt 2pt' % shorthand,
{top: '1pt', right: '4pt',
bottom: '2pt', left: '4pt'})

assert_resolves('%s: 1pt 4pt 2pt 0pt' % shorthand,
{top: '1pt', right: '4pt',
bottom: '2pt', left: '0pt'})
@pytest.mark.parametrize('inherited', [
None,
{'margin-top': '100pt', 'padding-right': '100pt',
'border-bottom-width': '100pt', 'border-left-color': 'green',
'border-top-style': 'dashed'},
])
def test_css_side_shorthands(shorthand, expansions, inherited):
top, right, bot, left = expansions

assert_same_resolution('%s: 1pt' % shorthand,
'{top}: 1pt; {right}: 1pt;'
'{bot}: 1pt; {left}: 1pt;'.format(**locals()),
inherited)

assert_same_resolution('%s: 1pt 4pt' % shorthand,
'{top}: 1pt; {right}: 4pt;'
'{bot}: 1pt; {left}: 4pt;'.format(**locals()),
inherited)

assert_same_resolution('%s: 1pt 4pt 2pt' % shorthand,
'{top}: 1pt; {right}: 4pt;'
'{bot}: 2pt; {left}: 4pt;'.format(**locals()),
inherited)

assert_same_resolution('%s: thin; %s: inherit auto inherit' %
(shorthand, shorthand),
'{top}: inherit; {right}: auto;'
'{bot}: inherit; {left}: auto;'.format(**locals()),
inherited)

assert_same_resolution('%s: 1pt 4pt 2pt 0pt' % shorthand,
'{top}: 1pt; {right}: 4pt;'
'{bot}: 2pt; {left}: 0pt;'.format(**locals()),
inherited)

with pytest.warns(CSSWarning):
assert_resolves('%s: 1pt 1pt 1pt 1pt 1pt' % shorthand,
Expand Down Expand Up @@ -147,17 +177,31 @@ def test_css_background_shorthand(css, props):
assert_resolves(css, props)


@pytest.mark.xfail(reason='CSS border shorthand not yet handled')
@pytest.mark.parametrize('style,equiv', [
('border: 1px solid red',
'border-width: 1px; border-style: solid; border-color: red'),
('border: solid red 1px',
'border-width: 1px; border-style: solid; border-color: red'),
('border: red solid',
'border-style: solid; border-color: red'),
('border{side}: {width} solid {color}',
'border{side}-width: {width}; border{side}-style: solid;' +
'border{side}-color: {color}'),
('border{side}: solid {color} {width}',
'border{side}-width: {width}; border{side}-style: solid;' +
'border{side}-color: {color}'),
('border{side}: {color} solid',
'border{side}-style: solid; border{side}-color: {color}'),
('border{side}: {width} {color} solid; border{side}: inherit',
'border{side}-width: inherit; border{side}-style: inherit;' +
'border{side}-color: inherit'),
])
def test_css_border_shorthand(style, equiv):
assert_same_resolution(style, equiv)
@pytest.mark.parametrize('side', ['', '-top', '-right', '-bottom', '-left'])
@pytest.mark.parametrize('width', ['1px', 'thin'])
@pytest.mark.parametrize('color', ['red', 'rgb(5, 10, 20)',
'RED', 'RGB(5, 10, 20)'])
@pytest.mark.parametrize('inherited', [
None,
{'border-bottom-width': '100pt', 'border-left-color': 'green',
'border-top-style': 'dashed'},
])
def test_css_border_shorthand(style, equiv, side, width, color, inherited):
assert_same_resolution(style.format(**locals()), equiv.format(**locals()),
inherited)


@pytest.mark.parametrize('style,inherited,equiv', [
Expand Down

0 comments on commit 839b1e0

Please sign in to comment.