From becabdf7f001fad5f9ceed6bddda22b906b04625 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 3 May 2017 16:03:14 +1000 Subject: [PATCH 1/9] WIP --- cssdecl.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cssdecl.py b/cssdecl.py index fe866cd..3fe6ae0 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -5,6 +5,8 @@ import re import warnings +import tinycss2 + __version__ = '0.1.3+dev' @@ -114,6 +116,9 @@ def resolve_string(self, declarations_str, inherited=None): return props + def resolve_parsed(self, declarations, inherited=None): + if inherited is None + UNIT_RATIOS = { 'rem': ('pt', 12), 'ex': ('em', .5), From 9db2d1cd45cf33475c9847fadee1515b9471b16a Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Mon, 8 May 2017 10:22:11 +1000 Subject: [PATCH 2/9] Use tinycss2 for parsing declaration lists --- cssdecl.py | 33 ++++++++++++++++++--------------- test_cssdecl.py | 3 --- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cssdecl.py b/cssdecl.py index 3fe6ae0..e4a9551 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -116,9 +116,6 @@ def resolve_string(self, declarations_str, inherited=None): return props - def resolve_parsed(self, declarations, inherited=None): - if inherited is None - UNIT_RATIOS = { 'rem': ('pt', 12), 'ex': ('em', .5), @@ -212,23 +209,29 @@ def _atomize(self, declarations): for prop, value in expand(prop, value): yield prop, value + def _clean_tokens(self, 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 _parse(self, declarations_str): """Generates (prop, value) pairs from declarations 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 = self._clean_tokens(decls) + for decl in decls: + value_str = tinycss2.serialize(decl.value).strip().lower() + yield decl.name.lower(), value_str class _CommonExpansions(object): diff --git a/test_cssdecl.py b/test_cssdecl.py index 431d836..0b454b3 100644 --- a/test_cssdecl.py +++ b/test_cssdecl.py @@ -34,9 +34,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') From c25ddf2accd80d04297ea809f8d1dc193005a049 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 18:25:25 +1000 Subject: [PATCH 3/9] Support border shorthand --- cssdecl.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++--- test_cssdecl.py | 23 ++++++++++------- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/cssdecl.py b/cssdecl.py index e4a9551..12a4b1b 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -4,8 +4,14 @@ import re import warnings +import functools +from collections import defaultdict import tinycss2 +import tinycss2.color3 + +# Unless there is no prospect for CSS3, should choose tinycss to avoid cleaning comments and parse errors at all points +# Though whitespace gets in the way in either case __version__ = '0.1.3+dev' @@ -18,6 +24,30 @@ class CSSWarning(UserWarning): pass +def match_tokens(tokens, matchers, remainder): + out = defaultdict(list) + for tok in tokens: + 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 + + +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 _BaseCSSResolver(object): """Base class for parsing and resolving CSS to atomic properties @@ -231,7 +261,7 @@ def _parse(self, declarations_str): decls = self._clean_tokens(decls) for decl in decls: value_str = tinycss2.serialize(decl.value).strip().lower() - yield decl.name.lower(), value_str + yield decl.lower_name, value_str class _CommonExpansions(object): @@ -245,7 +275,7 @@ class _CommonExpansions(object): def _side_expander(prop_fmt): def expand(self, prop, value): - tokens = value.split() + tokens = self._clean_tokens(tinycss2.parse_component_value_list(value)) try: mapping = self.SIDE_SHORTHANDS[len(tokens)] except KeyError: @@ -253,7 +283,7 @@ def expand(self, 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 @@ -263,6 +293,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 = self._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 diff --git a/test_cssdecl.py b/test_cssdecl.py index 0b454b3..3c298ff 100644 --- a/test_cssdecl.py +++ b/test_cssdecl.py @@ -144,17 +144,22 @@ 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}'), ]) -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)']) +def test_css_border_shorthand(style, equiv, side, width, color): + assert_same_resolution(style.format(**locals()), equiv.format(**locals())) @pytest.mark.parametrize('style,inherited,equiv', [ From b1ced4c0a450c529081a5d5b826dddfdb5ce1320 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 20:28:03 +1000 Subject: [PATCH 4/9] require tinycss2 --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index bcdb747..c042308 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,6 +13,8 @@ classifiers = Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 +install_requires = + tinycss2 [aliases] test = pytest From 2e6eccc69ea17e0f315dbe81c4e8c2bdebce9979 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 20:32:00 +1000 Subject: [PATCH 5/9] More specific dependency --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index c042308..e91b3eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,7 @@ classifiers = Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 install_requires = - tinycss2 + tinycss2~=0.5 [aliases] test = pytest From 6f7f4d28f809dc36c0d33e9038d97d8ca35f559f Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 20:33:30 +1000 Subject: [PATCH 6/9] install_requires in setup.py, not cfg --- setup.cfg | 2 -- setup.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index e91b3eb..bcdb747 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,8 +13,6 @@ classifiers = Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 -install_requires = - tinycss2~=0.5 [aliases] test = pytest diff --git a/setup.py b/setup.py index 4f64d6e..c561ae0 100644 --- a/setup.py +++ b/setup.py @@ -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) From 73ada8d1c81a5e6a13e4b93d6a2eff21af00ddc3 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 20:36:56 +1000 Subject: [PATCH 7/9] Fix setup.py --- cssdecl.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cssdecl.py b/cssdecl.py index 12a4b1b..a1153f7 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -4,14 +4,14 @@ import re import warnings -import functools from collections import defaultdict -import tinycss2 -import tinycss2.color3 - -# Unless there is no prospect for CSS3, should choose tinycss to avoid cleaning comments and parse errors at all points -# Though whitespace gets in the way in either case +try: + import tinycss2 + import tinycss2.color3 +except ImportError: + # currently needed for setup.cfg to get version :( + pass __version__ = '0.1.3+dev' From 9909f6ea624b8b197ca0dbf1e98e81b63ca6bc2f Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 20:42:46 +1000 Subject: [PATCH 8/9] flake8 --- cssdecl.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cssdecl.py b/cssdecl.py index a1153f7..2de180c 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -275,7 +275,8 @@ class _CommonExpansions(object): def _side_expander(prop_fmt): def expand(self, prop, value): - tokens = self._clean_tokens(tinycss2.parse_component_value_list(value)) + tokens = self._clean_tokens( + tinycss2.parse_component_value_list(value)) try: mapping = self.SIDE_SHORTHANDS[len(tokens)] except KeyError: @@ -283,7 +284,7 @@ def expand(self, prop, value): CSSWarning) return for key, idx in zip(self.SIDES, mapping): - yield prop_fmt % key, tinycss2.serialize(tokens[idx:idx+1]) + yield prop_fmt % key, tinycss2.serialize(tokens[idx:idx + 1]) return expand From 4447cc93c20f4580edb1f86599f2fc3e7cd0a8c5 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 10 May 2017 22:27:37 +1000 Subject: [PATCH 9/9] inherit in expansion --- cssdecl.py | 71 ++++++++++++++++++++++++++---------------- test_cssdecl.py | 82 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 107 insertions(+), 46 deletions(-) diff --git a/cssdecl.py b/cssdecl.py index 2de180c..d1df66b 100644 --- a/cssdecl.py +++ b/cssdecl.py @@ -24,17 +24,18 @@ class CSSWarning(UserWarning): pass -def match_tokens(tokens, matchers, remainder): - out = defaultdict(list) +def _clean_tokens(tokens): + cleaned = [] for tok in tokens: - for k, matcher in matchers.items(): - if matcher(tok): - out[k].append(tok) - break + 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: - out[remainder].append(tok) - out.default_factory = None - return out + cleaned.append(tok) + return cleaned def match_color_token(token): @@ -48,6 +49,38 @@ def match_size_token(token): '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 @@ -239,26 +272,13 @@ def _atomize(self, declarations): for prop, value in expand(prop, value): yield prop, value - def _clean_tokens(self, 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 _parse(self, declarations_str): """Generates (prop, value) pairs from declarations In a future version may generate parsed tokens from tinycss/tinycss2 """ decls = tinycss2.parse_declaration_list(declarations_str) - decls = self._clean_tokens(decls) + decls = _clean_tokens(decls) for decl in decls: value_str = tinycss2.serialize(decl.value).strip().lower() yield decl.lower_name, value_str @@ -275,8 +295,7 @@ class _CommonExpansions(object): def _side_expander(prop_fmt): def expand(self, prop, value): - tokens = self._clean_tokens( - tinycss2.parse_component_value_list(value)) + tokens = _clean_tokens(tinycss2.parse_component_value_list(value)) try: mapping = self.SIDE_SHORTHANDS[len(tokens)] except KeyError: @@ -309,7 +328,7 @@ def expand_border(self, prop, value, side=None): for side in sides: for k, v in matched.items(): if v: - v = self._clean_tokens(v) + v = _clean_tokens(v) yield 'border-%s-%s' % (side, k), tinycss2.serialize(v) def _border_side_expander(side): diff --git a/test_cssdecl.py b/test_cssdecl.py index 3c298ff..001894d 100644 --- a/test_cssdecl.py +++ b/test_cssdecl.py @@ -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) @@ -80,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', @@ -93,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, @@ -153,13 +186,22 @@ def test_css_background_shorthand(css, props): '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'), ]) @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)']) -def test_css_border_shorthand(style, equiv, side, width, color): - assert_same_resolution(style.format(**locals()), equiv.format(**locals())) +@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', [