From 6feae5a0aede60bdb3d6dd65646cf009536fbd8f Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 1 Jan 2020 19:08:13 -0500 Subject: [PATCH 01/32] Add font shorthand parser --- colosseum/fonts.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 colosseum/fonts.py diff --git a/colosseum/fonts.py b/colosseum/fonts.py new file mode 100644 index 000000000..b8ed2db0e --- /dev/null +++ b/colosseum/fonts.py @@ -0,0 +1,93 @@ +import re + +from .constants import (FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, + FONT_WEIGHT_CHOICES, INHERIT, INITIAL_FONT_VALUES, + NORMAL, SYSTEM_FONT_KEYWORDS) +from .validators import ValidationError + +# Find spaces within single and double quotes +SQ_PATTERN = re.compile(r"\s+(?=(?:(?:[^']*'){2})*[^']*'[^']*$)") +DQ_PATTERN = re.compile(r'\s+(?=(?:(?:[^"]*"){2})*[^"]*"[^"]*$)') + + +def get_system_font(keyword): + """Return a font object from given system font keyword.""" + if keyword in SYSTEM_FONT_KEYWORDS: + # Get the system font + font = INITIAL_FONT_VALUES.copy() + return font + + +def construct_font_property(font): + """Construct font property string from a dictionary of font properties.""" + return ('{font_style} {font_variant} {font_weight} ' + '{font_size}/{line_height} {font_family}').format(**font) + + +def replace_font_family_spaces(string, space_sep): + """Replace spaces between quotes by character.""" + string = SQ_PATTERN.sub(space_sep, string) + string = DQ_PATTERN.sub(space_sep, string) + return string + + +def parse_font_property(string): + """ + Parse font string into a dictionary of font properties. + + Reference: + - https://www.w3.org/TR/CSS22/fonts.html#font-shorthand + - https://developer.mozilla.org/en-US/docs/Web/CSS/font + """ + font = INITIAL_FONT_VALUES.copy() + comma, space = ', ', ' ' + comma_sep, space_sep = '~', '@' + + # Remove extra inner spaces + string = space.join(string.strip().split()) + + # Replace commas between font families with some character + string = string.replace(comma, comma_sep) + string = string.replace(comma[0], comma_sep) + string = replace_font_family_spaces(string, space_sep) + parts = string.split() + + if len(parts) == 1: + value = parts[0] + if value == INHERIT: + # ?? + pass + else: + if value not in SYSTEM_FONT_KEYWORDS: + # TODO: Should this be a different error, e.g. ParsingError? + raise ValidationError + font = get_system_font(value) + elif len(parts) <= 5: + font_properties, font_family = parts[:-1], parts[-1] + + # Restore original space characters + font_family = font_family.replace(comma_sep, comma) + font_family = font_family.replace(space_sep, space) + font['font_family'] = font_family + + # font size is be the last item on the rest of font properties + font['font_size'] = font_properties.pop(-1) + + for value in font_properties: + if value != NORMAL: + for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, + 'font_weight': FONT_WEIGHT_CHOICES, + 'font_style': FONT_STYLE_CHOICES}.items(): + try: + value = choices.validate(value) + font[property_name] = value + except (ValidationError, ValueError): + pass + + if '/' in font['font_size']: + font['font_size'], font['line_height'] = font['font_size'].split('/') + else: + # TODO: Should this be a different error, e.g. ParsingError? + raise ValidationError + + return font From c7b7dc51445f08320c3dd3535192e6c5ff213452 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 1 Jan 2020 19:08:39 -0500 Subject: [PATCH 02/32] Add font properties to declaration --- colosseum/constants.py | 86 +++++++++++++++++++++++++++++++++++++++- colosseum/declaration.py | 55 +++++++++++++++++++++---- colosseum/validators.py | 40 ++++++++++++++++++- 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index 83fcfb7da..49cf7aaf8 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,5 +1,6 @@ -from .validators import (is_color, is_integer, is_length, is_number, - is_percentage, ValidationError) +from .validators import (ValidationError, is_color, is_font_family, + is_integer, is_length, + is_number, is_percentage) class Choices: @@ -317,26 +318,107 @@ def __str__(self): ###################################################################### # font_family +FONT_FAMILY_CHOICES = Choices( + validators=[is_font_family], + explicit_defaulting_constants=[INHERIT, INITIAL], +) + ###################################################################### # 15.4 Font Styling ###################################################################### # font_style +NORMAL = 'normal' +ITALIC = 'italic' +OBLIQUE = 'oblique' + +FONT_STYLE_CHOICES = Choices( + NORMAL, + ITALIC, + OBLIQUE, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.5 Small-caps ###################################################################### # font_variant +SMALL_CAPS = 'small-caps' +FONT_VARIANT_CHOICES = Choices( + NORMAL, + SMALL_CAPS, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.6 Font boldness ###################################################################### # font_weight +BOLD = 'bold' +BOLDER = 'bolder' +LIGHTER = 'lighter' + +FONT_WEIGHT_CHOICES = Choices( + NORMAL, + BOLD, + BOLDER, + LIGHTER, + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.7 Font size ###################################################################### # font_size +# +XX_SMALL = 'xx-small' +X_SMALL = 'x-small' +SMALL = 'small' +MEDIUM = 'medium' +LARGE = 'large' +X_LARGE = 'x-large' +XX_LARGE = 'xx-large' + +# +LARGER = 'larger' +SMALLER = 'smaller' + +FONT_SIZE_CHOICES = Choices( + XX_SMALL, + X_SMALL, + SMALL, + MEDIUM, + LARGE, + X_LARGE, + XX_LARGE, + LARGER, + SMALLER, + validators=[is_length, is_percentage], + explicit_defaulting_constants=[INHERIT], +) + +###################################################################### +# 15.8 Font shorthand +###################################################################### +SYSTEM_FONT_KEYWORDS = ['caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'] +INITIAL_FONT_VALUES = { + 'font_style': NORMAL, + 'font_variant': NORMAL, + 'font_weight': NORMAL, + 'font_size': MEDIUM, + 'line_height': NORMAL, + 'font_family': '', # TODO: Depends on user agent. What to use? +} + ###################################################################### # 16. Text ########################################################### ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index f1a37ec41..c4228eaf1 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -12,12 +12,53 @@ MIN_SIZE_CHOICES, NORMAL, NOWRAP, ORDER_CHOICES, PADDING_CHOICES, POSITION_CHOICES, ROW, SIZE_CHOICES, STATIC, STRETCH, TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, VISIBLE, - Z_INDEX_CHOICES, default, + Z_INDEX_CHOICES, default, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, + FONT_WEIGHT_CHOICES, FONT_SIZE_CHOICES, MEDIUM, FONT_FAMILY_CHOICES, INITIAL, + INITIAL_FONT_VALUES, ) +from .fonts import construct_font_property, parse_font_property + _CSS_PROPERTIES = set() +def validated_font_property(name, initial): + """Define the shorthand CSS font property.""" + + def getter(self): + font = initial + for property_name, initial_value in font.items(): + font[property_name] = getattr(self, property_name, initial_value) + return getattr(self, name, construct_font_property(font)) + + def setter(self, value): + font = parse_font_property(value) + for property_name, property_value in font.items(): + setattr(self, property_name, property_value) + self.dirty = True + setattr(self, name, value) + + def deleter(self): + try: + delattr(self, name) + self.dirty = True + except AttributeError: + # Attribute doesn't exist + pass + + # TODO: Should this delete all the other attributes? + for property_name in INITIAL_FONT_VALUES: + try: + delattr(self, property_name) + self.dirty = True + except AttributeError: + # Attribute doesn't exist + pass + + _CSS_PROPERTIES.add(name) + return property(getter, setter, deleter) + + def unvalidated_property(name, choices, initial): "Define a simple CSS property attribute." initial = choices.validate(initial) @@ -276,22 +317,22 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - # font_family + font_family = validated_property('font_family', choices=FONT_FAMILY_CHOICES, initial=INITIAL) # TODO: initial? # 15.4 Font Styling - # font_style + font_style = validated_property('font_style', choices=FONT_STYLE_CHOICES, initial=NORMAL) # 15.5 Small-caps - # font_variant + font_variant = validated_property('font_variant', choices=FONT_VARIANT_CHOICES, initial=NORMAL) # 15.6 Font boldness - # font_weight + font_weight = validated_property('font_weight', choices=FONT_WEIGHT_CHOICES, initial=NORMAL) # 15.7 Font size - # font_size + font_size = validated_property('font_size', choices=FONT_SIZE_CHOICES, initial=MEDIUM) # 15.8 Shorthand font property - # font + font = validated_font_property('font', initial=INITIAL_FONT_VALUES) # 16. Text ########################################################### # 16.1 Indentation diff --git a/colosseum/validators.py b/colosseum/validators.py index a49e56dd7..1e17ec7af 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -1,7 +1,9 @@ +import ast +import re + from . import parser from . import units - class ValidationError(ValueError): pass @@ -103,3 +105,39 @@ def is_color(value): is_color.description = '' + + +_CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') + + +def is_font_family(value): + """Validate that value is a valid font family.""" + value = ' '.join(value.strip().split()) + values = [v.strip() for v in value.split(',')] + checked_values = [] + generic_family = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'] + for val in values: + if (val.startswith('"') and val.endswith('"') + or val.startswith("'") and val.endswith("'")): + # TODO: Check that the font exists? + try: + ast.literal_eval(val) + checked_values.append(val) + except: + raise ValidationError + elif val in generic_family: + checked_values.append(val) + else: + # TODO: Check that the font exists? + if _CSS_IDENTIFIER_RE.match(val): + checked_values.append(val) + else: + raise ValidationError + + if len(checked_values) != len(values): + raise ValidationError + + return ', '.join(checked_values) + + +is_font_family.description = ', ' From e1f140156965d60069832b61ecfdfa346411bd45 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 1 Jan 2020 19:08:52 -0500 Subject: [PATCH 03/32] Add tests for font parser and properties --- tests/test_fonts.py | 62 ++++++++++++++++++++++++++++++++++++++++ tests/test_validators.py | 23 ++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/test_fonts.py diff --git a/tests/test_fonts.py b/tests/test_fonts.py new file mode 100644 index 000000000..1b8c2df3c --- /dev/null +++ b/tests/test_fonts.py @@ -0,0 +1,62 @@ +from unittest import TestCase + +from colosseum.fonts import parse_font_property +from colosseum.validators import ValidationError + + +FONT_CASES = { + r'12px/14px sans-serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': '12px', + 'line_height': '14px', + 'font_family': 'sans-serif', + }, + r'80% sans-serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': '80%', + 'line_height': 'normal', + 'font_family': 'sans-serif', + }, + r'bold italic large Palatino, serif': { + 'font_style': 'italic', + 'font_variant': 'normal', + 'font_weight': 'bold', + 'font_size': 'large', + 'line_height': 'normal', + 'font_family': 'Palatino, serif', + }, + r'normal small-caps 120%/120% fantasy': { + 'font_style': 'normal', + 'font_variant': 'small-caps', + 'font_weight': 'normal', + 'font_size': '120%', + 'line_height': '120%', + 'font_family': 'fantasy', + }, + r'x-large/110% "New Century Schoolbook",serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': 'x-large', + 'line_height': '110%', + 'font_family': '"New Century Schoolbook", serif', + }, +} + + +class FontTests(TestCase): + def test_parse_font_shorthand(self): + for case in sorted(FONT_CASES): + expected_output = FONT_CASES[case] + font = parse_font_property(case) + self.assertEqual(font, expected_output) + + # Test extra spaces + parse_font_property(r' normal normal normal 12px/12px serif ') + + with self.assertRaises(ValidationError): + font = parse_font_property(r'normal normal normal normal 12px/12px serif') diff --git a/tests/test_validators.py b/tests/test_validators.py index 1550446cc..d9fb55226 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,6 +1,7 @@ from unittest import TestCase -from colosseum.validators import is_integer, is_number, ValidationError +from colosseum.validators import (ValidationError, is_font_family, + is_integer, is_number) class NumericTests(TestCase): @@ -38,3 +39,23 @@ def test_number(self): with self.assertRaises(ValidationError): validator('spam') + + +class FontTests(TestCase): + def test_font_family_name(self): + validator = is_font_family + invalid_cases = [ + 'Red/Black, sans-serif', + '"Lucida" Grande, sans-serif', + 'Ahem!, sans-serif', + 'test@foo, sans-serif', + '#POUND, sans-serif', + 'Hawaii 5-0, sans-serif', + '123', + ] + for case in invalid_cases: + with self.assertRaises(ValidationError): + validator(case) + + self.assertEqual(validator('"New Century Schoolbook", serif'), '"New Century Schoolbook", serif') + self.assertEqual(validator("'21st Century',fantasy"), "'21st Century', fantasy") From ba2cc2f67d3a1722bd272a8e04be25a7f2094c54 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 1 Jan 2020 19:11:34 -0500 Subject: [PATCH 04/32] Fix codestyle --- colosseum/validators.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/colosseum/validators.py b/colosseum/validators.py index 1e17ec7af..ca0124848 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -4,6 +4,7 @@ from . import parser from . import units + class ValidationError(ValueError): pass @@ -123,7 +124,7 @@ def is_font_family(value): try: ast.literal_eval(val) checked_values.append(val) - except: + except ValueError: raise ValidationError elif val in generic_family: checked_values.append(val) From 32b9f47cd991d32eb49e113c2a0d9f485e237b78 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Thu, 2 Jan 2020 01:06:05 -0500 Subject: [PATCH 05/32] Add font_family list property, fix code review comments --- colosseum/constants.py | 23 ++++++++++++++-- colosseum/declaration.py | 58 ++++++++++++++++++++++++++++++++-------- colosseum/fonts.py | 9 ++++--- colosseum/validators.py | 55 +++++++++++++++++++++---------------- tests/test_validators.py | 3 ++- 5 files changed, 107 insertions(+), 41 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index 49cf7aaf8..a8ba68711 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -236,6 +236,8 @@ def __str__(self): # 10.8 Leading and half-leading ###################################################################### # line_height +LINE_HEIGHT_CHOICES = Choices(NORMAL, validators=[is_number, is_length, is_percentage], + explicit_defaulting_constants=[INHERIT]) # vertical_align ###################################################################### @@ -318,8 +320,16 @@ def __str__(self): ###################################################################### # font_family +SERIF = 'serif' +SANS_SERIF = 'sans-serif' +CURSIVE = 'cursive' +FANTASY = 'fantasy' +MONOSPACE = 'monospace' + +GENERIC_FAMILY_FONTS = [SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE] + FONT_FAMILY_CHOICES = Choices( - validators=[is_font_family], + validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS)], explicit_defaulting_constants=[INHERIT, INITIAL], ) @@ -409,7 +419,16 @@ def __str__(self): ###################################################################### # 15.8 Font shorthand ###################################################################### -SYSTEM_FONT_KEYWORDS = ['caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'] + +ICON = 'icon' +CAPTION = 'caption' +MENU = 'menu' +MESSAGE_BOX = 'message-box' +SMALL_CAPTION = 'small-caption' +STATUS_BAR = 'status-bar' + +SYSTEM_FONT_KEYWORDS = [ICON, CAPTION, MENU, MESSAGE_BOX, SMALL_CAPTION, STATUS_BAR] + INITIAL_FONT_VALUES = { 'font_style': NORMAL, 'font_variant': NORMAL, diff --git a/colosseum/declaration.py b/colosseum/declaration.py index c4228eaf1..712c208d6 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -14,7 +14,7 @@ TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, VISIBLE, Z_INDEX_CHOICES, default, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, FONT_SIZE_CHOICES, MEDIUM, FONT_FAMILY_CHOICES, INITIAL, - INITIAL_FONT_VALUES, + INITIAL_FONT_VALUES, LINE_HEIGHT_CHOICES, ) from .fonts import construct_font_property, parse_font_property @@ -46,7 +46,6 @@ def deleter(self): # Attribute doesn't exist pass - # TODO: Should this delete all the other attributes? for property_name in INITIAL_FONT_VALUES: try: delattr(self, property_name) @@ -59,21 +58,34 @@ def deleter(self): return property(getter, setter, deleter) -def unvalidated_property(name, choices, initial): - "Define a simple CSS property attribute." - initial = choices.validate(initial) +def validated_list_property(name, choices, initial): + """Define a property holding a list of comma separated values.""" + if not isinstance(initial, list): + raise ValueError('Initial value must be a list!') def getter(self): - return getattr(self, '_%s' % name, initial) + return getattr(self, name, initial) def setter(self, value): - if value != getattr(self, '_%s' % name, initial): - setattr(self, '_%s' % name, value) + try: + if not isinstance(value, str): + value = ', '.join(value) + + value = ' '.join(value.strip().split()) + value = choices.validate(value) + values = [v.strip() for v in value.split(',')] + except ValueError: + raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( + value, name, choices + )) + + if values != getattr(self, name, initial): + setattr(self, name, values) self.dirty = True def deleter(self): try: - delattr(self, '_%s' % name) + delattr(self, name) self.dirty = True except AttributeError: # Attribute doesn't exist @@ -168,6 +180,30 @@ def deleter(self): return property(getter, setter, deleter) +def unvalidated_property(name, choices, initial): + "Define a simple CSS property attribute." + initial = choices.validate(initial) + + def getter(self): + return getattr(self, '_%s' % name, initial) + + def setter(self, value): + if value != getattr(self, '_%s' % name, initial): + setattr(self, '_%s' % name, value) + self.dirty = True + + def deleter(self): + try: + delattr(self, '_%s' % name) + self.dirty = True + except AttributeError: + # Attribute doesn't exist + pass + + _CSS_PROPERTIES.add(name) + return property(getter, setter, deleter) + + class CSS: def __init__(self, **style): self._node = None @@ -263,7 +299,7 @@ def __init__(self, **style): max_height = validated_property('max_height', choices=MAX_SIZE_CHOICES, initial=None) # 10.8 Leading and half-leading - # line_height + line_height = validated_property('line_height', choices=LINE_HEIGHT_CHOICES, initial=NORMAL) # vertical_align # 11. Visual effects ################################################# @@ -317,7 +353,7 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - font_family = validated_property('font_family', choices=FONT_FAMILY_CHOICES, initial=INITIAL) # TODO: initial? + font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=[INITIAL]) # 15.4 Font Styling font_style = validated_property('font_style', choices=FONT_STYLE_CHOICES, initial=NORMAL) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index b8ed2db0e..d89ebcadf 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -59,8 +59,9 @@ def parse_font_property(string): pass else: if value not in SYSTEM_FONT_KEYWORDS: - # TODO: Should this be a different error, e.g. ParsingError? - raise ValidationError + error_msg = ('Font property value "{value}" ' + 'not a system font keyword!'.format(value=value)) + raise ValidationError(error_msg) font = get_system_font(value) elif len(parts) <= 5: font_properties, font_family = parts[:-1], parts[-1] @@ -87,7 +88,7 @@ def parse_font_property(string): if '/' in font['font_size']: font['font_size'], font['line_height'] = font['font_size'].split('/') else: - # TODO: Should this be a different error, e.g. ParsingError? - raise ValidationError + error_msg = ('Font property shorthand contains too many parts!') + raise ValidationError(error_msg) return font diff --git a/colosseum/validators.py b/colosseum/validators.py index ca0124848..4ca1c7553 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -111,34 +111,43 @@ def is_color(value): _CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') -def is_font_family(value): +def is_font_family(value=None, generic_family=None): """Validate that value is a valid font family.""" - value = ' '.join(value.strip().split()) - values = [v.strip() for v in value.split(',')] - checked_values = [] - generic_family = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'] - for val in values: - if (val.startswith('"') and val.endswith('"') - or val.startswith("'") and val.endswith("'")): - # TODO: Check that the font exists? - try: - ast.literal_eval(val) - checked_values.append(val) - except ValueError: - raise ValidationError - elif val in generic_family: - checked_values.append(val) - else: - # TODO: Check that the font exists? - if _CSS_IDENTIFIER_RE.match(val): + + def validator(font_value): + font_value = ' '.join(font_value.strip().split()) + values = [v.strip() for v in font_value.split(',')] + checked_values = [] + generic_family = generic_family or [] + for val in values: + if (val.startswith('"') and val.endswith('"') + or val.startswith("'") and val.endswith("'")): + # TODO: Check that the font exists? + try: + ast.literal_eval(val) + checked_values.append(val) + except ValueError: + raise ValidationError + elif val in generic_family: checked_values.append(val) else: - raise ValidationError + # TODO: Check that the font exists? + if _CSS_IDENTIFIER_RE.match(val): + checked_values.append(val) + else: + raise ValidationError + + if len(checked_values) != len(values): + invalid = set(values) - set(checked_values) + error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) + raise ValidationError(error_msg) - if len(checked_values) != len(values): - raise ValidationError + return ', '.join(checked_values) - return ', '.join(checked_values) + if generic_family is None: + return validator(value) + else: + return validator is_font_family.description = ', ' diff --git a/tests/test_validators.py b/tests/test_validators.py index d9fb55226..10c9f16bd 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,5 +1,6 @@ from unittest import TestCase +from colosseum.constants import GENERIC_FAMILY_FONTS from colosseum.validators import (ValidationError, is_font_family, is_integer, is_number) @@ -43,7 +44,7 @@ def test_number(self): class FontTests(TestCase): def test_font_family_name(self): - validator = is_font_family + validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) invalid_cases = [ 'Red/Black, sans-serif', '"Lucida" Grande, sans-serif', From f497c6f326e923ce046b1cf4e275e4abed2d1a02 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Thu, 2 Jan 2020 01:18:48 -0500 Subject: [PATCH 06/32] Fix code style --- colosseum/constants.py | 2 +- colosseum/declaration.py | 18 +++++++++--------- colosseum/fonts.py | 1 + colosseum/validators.py | 4 ++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index a8ba68711..eeebddca1 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -435,7 +435,7 @@ def __str__(self): 'font_weight': NORMAL, 'font_size': MEDIUM, 'line_height': NORMAL, - 'font_family': '', # TODO: Depends on user agent. What to use? + 'font_family': [INITIAL], # TODO: Depends on user agent. What to use? } ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 712c208d6..1a9372d76 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -28,19 +28,19 @@ def validated_font_property(name, initial): def getter(self): font = initial for property_name, initial_value in font.items(): - font[property_name] = getattr(self, property_name, initial_value) - return getattr(self, name, construct_font_property(font)) + font[property_name] = getattr(self, '_%s' % property_name, initial_value) + return getattr(self, '_%s' % name, construct_font_property(font)) def setter(self, value): font = parse_font_property(value) for property_name, property_value in font.items(): - setattr(self, property_name, property_value) + setattr(self, '_%s' % property_name, property_value) self.dirty = True setattr(self, name, value) def deleter(self): try: - delattr(self, name) + delattr(self, '_%s' % name) self.dirty = True except AttributeError: # Attribute doesn't exist @@ -48,7 +48,7 @@ def deleter(self): for property_name in INITIAL_FONT_VALUES: try: - delattr(self, property_name) + delattr(self, '_%s' % property_name) self.dirty = True except AttributeError: # Attribute doesn't exist @@ -64,7 +64,7 @@ def validated_list_property(name, choices, initial): raise ValueError('Initial value must be a list!') def getter(self): - return getattr(self, name, initial) + return getattr(self, '_%s' % name, initial) def setter(self, value): try: @@ -79,13 +79,13 @@ def setter(self, value): value, name, choices )) - if values != getattr(self, name, initial): - setattr(self, name, values) + if values != getattr(self, '_%s' % name, initial): + setattr(self, '_%s' % name, values) self.dirty = True def deleter(self): try: - delattr(self, name) + delattr(self, '_%s' % name) self.dirty = True except AttributeError: # Attribute doesn't exist diff --git a/colosseum/fonts.py b/colosseum/fonts.py index d89ebcadf..86570a8a8 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -20,6 +20,7 @@ def get_system_font(keyword): def construct_font_property(font): """Construct font property string from a dictionary of font properties.""" + font['font_family'] = ', '.join(font['font_family']) return ('{font_style} {font_variant} {font_weight} ' '{font_size}/{line_height} {font_family}').format(**font) diff --git a/colosseum/validators.py b/colosseum/validators.py index 4ca1c7553..e6d2abb5e 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -113,12 +113,12 @@ def is_color(value): def is_font_family(value=None, generic_family=None): """Validate that value is a valid font family.""" + generic_family = generic_family or [] def validator(font_value): font_value = ' '.join(font_value.strip().split()) values = [v.strip() for v in font_value.split(',')] checked_values = [] - generic_family = generic_family or [] for val in values: if (val.startswith('"') and val.endswith('"') or val.startswith("'") and val.endswith("'")): @@ -144,7 +144,7 @@ def validator(font_value): return ', '.join(checked_values) - if generic_family is None: + if generic_family is []: return validator(value) else: return validator From fc1411465314eeb1105104f5c78e5f9bbfd4d9a5 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 01:33:26 -0500 Subject: [PATCH 07/32] Update list property and tests --- colosseum/constants.py | 8 +-- colosseum/declaration.py | 31 +++++++----- colosseum/fonts.py | 102 +++++++++++++++++++------------------- colosseum/validators.py | 19 ++++++- tests/test_declaration.py | 84 +++++++++++++++++++++++++++++++ tests/test_fonts.py | 25 +++++++--- tests/test_validators.py | 11 ++-- 7 files changed, 200 insertions(+), 80 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index eeebddca1..ae494a571 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -35,15 +35,15 @@ def validate(self, value): raise ValueError() def __str__(self): - choices = set([str(c).lower().replace('_', '-') for c in self.constants]) + choices = [str(c).lower().replace('_', '-') for c in self.constants] for validator in self.validators: - choices.add(validator.description) + choices += validator.description.split(', ') if self.explicit_defaulting_constants: for item in self.explicit_defaulting_constants: - choices.add(item) + choices.append(item) - return ", ".join(sorted(choices)) + return ", ".join(sorted(set(choices))) ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 1a9372d76..56d934374 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -24,19 +24,21 @@ def validated_font_property(name, initial): """Define the shorthand CSS font property.""" + assert isinstance(initial, dict) + initial = initial.copy() def getter(self): font = initial - for property_name, initial_value in font.items(): - font[property_name] = getattr(self, '_%s' % property_name, initial_value) + for property_name in font: + font[property_name] = getattr(self, property_name) return getattr(self, '_%s' % name, construct_font_property(font)) def setter(self, value): font = parse_font_property(value) for property_name, property_value in font.items(): - setattr(self, '_%s' % property_name, property_value) + setattr(self, property_name, property_value) self.dirty = True - setattr(self, name, value) + setattr(self, '_%s' % name, value) def deleter(self): try: @@ -48,7 +50,7 @@ def deleter(self): for property_name in INITIAL_FONT_VALUES: try: - delattr(self, '_%s' % property_name) + delattr(self, property_name) self.dirty = True except AttributeError: # Attribute doesn't exist @@ -58,29 +60,32 @@ def deleter(self): return property(getter, setter, deleter) -def validated_list_property(name, choices, initial): - """Define a property holding a list of comma separated values.""" +def validated_list_property(name, choices, initial, separator=','): + """Define a property holding a list values.""" if not isinstance(initial, list): raise ValueError('Initial value must be a list!') def getter(self): - return getattr(self, '_%s' % name, initial) + # TODO: A copy is returned so if the user mutates it, + # it will not affect the stored value + return getattr(self, '_%s' % name, initial)[:] def setter(self, value): try: if not isinstance(value, str): - value = ', '.join(value) + value = separator.join(value) - value = ' '.join(value.strip().split()) - value = choices.validate(value) - values = [v.strip() for v in value.split(',')] + # This should be a list of values + values = choices.validate(value) + if not isinstance(values, list): + values.split(separator) except ValueError: raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( value, name, choices )) if values != getattr(self, '_%s' % name, initial): - setattr(self, '_%s' % name, values) + setattr(self, '_%s' % name, values[:]) self.dirty = True def deleter(self): diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 86570a8a8..f6ea64202 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -1,14 +1,9 @@ -import re - -from .constants import (FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, +from .constants import (FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, + FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, INHERIT, INITIAL_FONT_VALUES, NORMAL, SYSTEM_FONT_KEYWORDS) from .validators import ValidationError -# Find spaces within single and double quotes -SQ_PATTERN = re.compile(r"\s+(?=(?:(?:[^']*'){2})*[^']*'[^']*$)") -DQ_PATTERN = re.compile(r'\s+(?=(?:(?:[^"]*"){2})*[^"]*"[^"]*$)') - def get_system_font(keyword): """Return a font object from given system font keyword.""" @@ -20,16 +15,41 @@ def get_system_font(keyword): def construct_font_property(font): """Construct font property string from a dictionary of font properties.""" - font['font_family'] = ', '.join(font['font_family']) + if isinstance(font['font_family'], list): + font['font_family'] = ', '.join(font['font_family']) + return ('{font_style} {font_variant} {font_weight} ' '{font_size}/{line_height} {font_family}').format(**font) -def replace_font_family_spaces(string, space_sep): - """Replace spaces between quotes by character.""" - string = SQ_PATTERN.sub(space_sep, string) - string = DQ_PATTERN.sub(space_sep, string) - return string +def parse_font_property_part(value, font): + """Parse font shorthand property part for known properties.""" + if value != NORMAL: + for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, + 'font_weight': FONT_WEIGHT_CHOICES, + 'font_style': FONT_STYLE_CHOICES}.items(): + try: + value = choices.validate(value) + font[property_name] = value + return font + except (ValidationError, ValueError): + pass + + # Maybe it is a font size + if '/' in value: + font['font_size'], font['line_height'] = value.split('/') + return font + else: + try: + FONT_SIZE_CHOICES.validate(value) + font['font_size'] = value + return font + except ValueError: + pass + + raise ValidationError + + return font def parse_font_property(string): @@ -37,26 +57,19 @@ def parse_font_property(string): Parse font string into a dictionary of font properties. Reference: - - https://www.w3.org/TR/CSS22/fonts.html#font-shorthand + - https://www.w3.org/TR/CSS21/fonts.html#font-shorthand - https://developer.mozilla.org/en-US/docs/Web/CSS/font """ font = INITIAL_FONT_VALUES.copy() - comma, space = ', ', ' ' - comma_sep, space_sep = '~', '@' - - # Remove extra inner spaces - string = space.join(string.strip().split()) - # Replace commas between font families with some character - string = string.replace(comma, comma_sep) - string = string.replace(comma[0], comma_sep) - string = replace_font_family_spaces(string, space_sep) - parts = string.split() + # Remove extra spaces + string = ' '.join(string.strip().split()) + parts = string.split(' ', 1) if len(parts) == 1: value = parts[0] if value == INHERIT: - # ?? + # TODO: ?? pass else: if value not in SYSTEM_FONT_KEYWORDS: @@ -64,32 +77,19 @@ def parse_font_property(string): 'not a system font keyword!'.format(value=value)) raise ValidationError(error_msg) font = get_system_font(value) - elif len(parts) <= 5: - font_properties, font_family = parts[:-1], parts[-1] - - # Restore original space characters - font_family = font_family.replace(comma_sep, comma) - font_family = font_family.replace(space_sep, space) - font['font_family'] = font_family - - # font size is be the last item on the rest of font properties - font['font_size'] = font_properties.pop(-1) - - for value in font_properties: - if value != NORMAL: - for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, - 'font_weight': FONT_WEIGHT_CHOICES, - 'font_style': FONT_STYLE_CHOICES}.items(): - try: - value = choices.validate(value) - font[property_name] = value - except (ValidationError, ValueError): - pass - - if '/' in font['font_size']: - font['font_size'], font['line_height'] = font['font_size'].split('/') else: - error_msg = ('Font property shorthand contains too many parts!') - raise ValidationError(error_msg) + for _ in range(5): + value = parts[0] + try: + font = parse_font_property_part(value, font) + parts = parts[-1].split(' ', 1) + except ValidationError: + break + else: + # Font family can have a maximum of 4 parts before the font_family part + raise ValidationError('Font property shorthand contains too many parts!') + + value = ' '.join(parts) + font['font_family'] = FONT_FAMILY_CHOICES.validate(value) return font diff --git a/colosseum/validators.py b/colosseum/validators.py index e6d2abb5e..2b0474607 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -43,12 +43,14 @@ def validator(num_value): if min_value is None and max_value is None: return validator(value) else: + validator.description = '' return validator is_number.description = '' + def is_integer(value=None, min_value=None, max_value=None): """ Validate that value is a valid integer. @@ -62,6 +64,7 @@ def validator(num_value): if min_value is None and max_value is None: return validator(value) else: + validator.description = '' return validator @@ -108,11 +111,16 @@ def is_color(value): is_color.description = '' +# https://www.w3.org/TR/2011/REC-CSS2-20110607/syndata.html#value-def-identifier _CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') def is_font_family(value=None, generic_family=None): - """Validate that value is a valid font family.""" + """ + Validate that value is a valid font family. + + This validator returns a list. + """ generic_family = generic_family or [] def validator(font_value): @@ -120,6 +128,12 @@ def validator(font_value): values = [v.strip() for v in font_value.split(',')] checked_values = [] for val in values: + # Remove extra inner spaces + val = val.replace('" ', '"') + val = val.replace(' "', '"') + val = val.replace("' ", "'") + val = val.replace(" '", "'") + if (val.startswith('"') and val.endswith('"') or val.startswith("'") and val.endswith("'")): # TODO: Check that the font exists? @@ -142,11 +156,12 @@ def validator(font_value): error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) raise ValidationError(error_msg) - return ', '.join(checked_values) + return checked_values if generic_family is []: return validator(value) else: + validator.description = ', ' return validator diff --git a/tests/test_declaration.py b/tests/test_declaration.py index b49a39264..1cc54df38 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -434,6 +434,39 @@ def test_property_with_choices(self): self.assertIs(node.style.display, INLINE) self.assertTrue(node.style.dirty) + def test_list_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check initial value + self.assertEqual(node.style.font_family, ['initial']) + + # Check valid values + node.style.font_family = ['caption'] + node.style.font_family = ['serif'] + node.style.font_family = ["'Lucida Foo Bar'", 'serif'] + + # TODO: This will coerce to a list, is this a valid behavior? + node.style.font_family = 'just-a-string' + self.assertEqual(node.style.font_family, ['just-a-string']) + node.style.font_family = ' just-a-string , caption ' + self.assertEqual(node.style.font_family, ['just-a-string', 'caption']) + + # Check invalid values + with self.assertRaises(ValueError): + node.style.font_family = ['Lucida Foo Bar'] + + # Check the error message + try: + node.style.font_family = ['123'] + self.fail('Should raise ValueError') + except ValueError as v: + self.assertEqual( + str(v), + ("Invalid value '123' for CSS property 'font_family'; Valid values are: " + ", , inherit, initial") + ) + def test_directional_property(self): node = TestNode(style=CSS()) node.layout.dirty = None @@ -657,3 +690,54 @@ def test_dict(self): with self.assertRaises(KeyError): del node.style['no-such-property'] + + + def test_font_shorthand_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check initial value + self.assertEqual(node.style.font, 'normal normal normal medium/normal initial') + + # Check Initial values + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.font_size, 'medium') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(node.style.font_family, ['initial']) + + # Check individual properties update the unset shorthand + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + node.style.font_family = ['"Foo Bar Spam"', 'serif'] + # TODO: Is this the behavior we want? + self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "Foo Bar Spam", serif') + + # Check setting the shorthand resets values + node.style.font = '9px serif' + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(str(node.style.font_size), '9.0px') + self.assertEqual(node.style.font_family, ['serif']) + self.assertEqual(node.style.font, '9px serif') + + # Check individual properties do not update the set shorthand + # TODO: Is this the behavior we want? + node.style.font = '9px serif' + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + node.style.font_family = ['"Foo Bar Spam"', 'serif'] + self.assertEqual(node.style.font, '9px serif') + + # Check invalid values + with self.assertRaises(ValueError): + node.style.font = 'foobar' diff --git a/tests/test_fonts.py b/tests/test_fonts.py index 1b8c2df3c..c6ba1673a 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -1,5 +1,6 @@ from unittest import TestCase +from colosseum.constants import SYSTEM_FONT_KEYWORDS from colosseum.fonts import parse_font_property from colosseum.validators import ValidationError @@ -11,7 +12,7 @@ 'font_weight': 'normal', 'font_size': '12px', 'line_height': '14px', - 'font_family': 'sans-serif', + 'font_family': ['sans-serif'], }, r'80% sans-serif': { 'font_style': 'normal', @@ -19,7 +20,7 @@ 'font_weight': 'normal', 'font_size': '80%', 'line_height': 'normal', - 'font_family': 'sans-serif', + 'font_family': ['sans-serif'], }, r'bold italic large Palatino, serif': { 'font_style': 'italic', @@ -27,7 +28,7 @@ 'font_weight': 'bold', 'font_size': 'large', 'line_height': 'normal', - 'font_family': 'Palatino, serif', + 'font_family': ['Palatino', 'serif'], }, r'normal small-caps 120%/120% fantasy': { 'font_style': 'normal', @@ -35,7 +36,7 @@ 'font_weight': 'normal', 'font_size': '120%', 'line_height': '120%', - 'font_family': 'fantasy', + 'font_family': ['fantasy'], }, r'x-large/110% "New Century Schoolbook",serif': { 'font_style': 'normal', @@ -43,7 +44,7 @@ 'font_weight': 'normal', 'font_size': 'x-large', 'line_height': '110%', - 'font_family': '"New Century Schoolbook", serif', + 'font_family': ['"New Century Schoolbook"', 'serif'], }, } @@ -57,6 +58,18 @@ def test_parse_font_shorthand(self): # Test extra spaces parse_font_property(r' normal normal normal 12px/12px serif ') + parse_font_property(r' normal normal normal 12px/12px "New Foo Bar", serif ') + # Test valid single part + for part in SYSTEM_FONT_KEYWORDS: + parse_font_property(part) + + def test_parse_font_shorthand_invalid(self): + # This font string has too many parts with self.assertRaises(ValidationError): - font = parse_font_property(r'normal normal normal normal 12px/12px serif') + parse_font_property(r'normal normal normal normal 12px/12px serif') + + # This invalid single part + for part in ['normal', 'foobar']: + with self.assertRaises(ValidationError): + parse_font_property(part) diff --git a/tests/test_validators.py b/tests/test_validators.py index 10c9f16bd..6f22569f7 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -43,7 +43,13 @@ def test_number(self): class FontTests(TestCase): - def test_font_family_name(self): + def test_font_family_name_valid(self): + validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) + self.assertEqual(validator('"New Century Schoolbook", serif'), ['"New Century Schoolbook"', 'serif']) + self.assertEqual(validator("'21st Century',fantasy"), ["'21st Century'", 'fantasy']) + self.assertEqual(validator(" ' 21st Century ' , fantasy "), ["'21st Century'", 'fantasy']) + + def test_font_family_name_invalid(self): validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) invalid_cases = [ 'Red/Black, sans-serif', @@ -57,6 +63,3 @@ def test_font_family_name(self): for case in invalid_cases: with self.assertRaises(ValidationError): validator(case) - - self.assertEqual(validator('"New Century Schoolbook", serif'), '"New Century Schoolbook", serif') - self.assertEqual(validator("'21st Century',fantasy"), "'21st Century', fantasy") From 3fad53ccd41718e833719060aa7b615efd329947 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 01:36:43 -0500 Subject: [PATCH 08/32] Fix code style --- colosseum/validators.py | 1 - tests/test_declaration.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/colosseum/validators.py b/colosseum/validators.py index 2b0474607..27126e958 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -50,7 +50,6 @@ def validator(num_value): is_number.description = '' - def is_integer(value=None, min_value=None, max_value=None): """ Validate that value is a valid integer. diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 1cc54df38..237fcd5a0 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -691,7 +691,6 @@ def test_dict(self): with self.assertRaises(KeyError): del node.style['no-such-property'] - def test_font_shorthand_property(self): node = TestNode(style=CSS()) node.layout.dirty = None @@ -735,7 +734,7 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"Foo Bar Spam"', 'serif'] + node.style.font_family = ['"Foo Bar Spam"', 'serif'] self.assertEqual(node.style.font, '9px serif') # Check invalid values From c4cc7bb25563f1954b58317fe90e3286daf602bc Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 15:05:02 -0500 Subject: [PATCH 09/32] Add initial support to search for fonts on mac with rubicon --- colosseum/constants.py | 26 +++++++++++++++++++++++++- colosseum/fonts.py | 2 ++ colosseum/validators.py | 12 +++++++++--- tests/test_declaration.py | 10 +++++----- tests/test_fonts.py | 6 +++--- tests/test_validators.py | 8 ++++---- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index ae494a571..7fd6bba55 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,3 +1,5 @@ +import sys + from .validators import (ValidationError, is_color, is_font_family, is_integer, is_length, is_number, is_percentage) @@ -328,8 +330,30 @@ def __str__(self): GENERIC_FAMILY_FONTS = [SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE] + +def available_font_families(): + """List available font family names.""" + if sys.platform == 'darwin': + return _available_font_families_mac() + return [] + + +def _available_font_families_mac(): + """List available font family names on mac.""" + from ctypes import cdll, util + from rubicon.objc import ObjCClass + appkit = cdll.LoadLibrary(util.find_library('AppKit')) + NSFontManager = ObjCClass("NSFontManager") + NSFontManager.declare_class_property('sharedFontManager') + NSFontManager.declare_class_property("sharedFontManager") + NSFontManager.declare_property("availableFontFamilies") + manager = NSFontManager.sharedFontManager + return list(sorted(str(item) for item in manager.availableFontFamilies)) + + +AVAILABLE_FONT_FAMILIES = available_font_families() FONT_FAMILY_CHOICES = Choices( - validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS)], + validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=AVAILABLE_FONT_FAMILIES)], explicit_defaulting_constants=[INHERIT, INITIAL], ) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index f6ea64202..9edd1ab2a 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -1,3 +1,5 @@ +import sys + from .constants import (FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, INHERIT, INITIAL_FONT_VALUES, diff --git a/colosseum/validators.py b/colosseum/validators.py index 27126e958..7b1b41fd2 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -114,13 +114,14 @@ def is_color(value): _CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') -def is_font_family(value=None, generic_family=None): +def is_font_family(value=None, generic_family=None, font_families=None): """ Validate that value is a valid font family. This validator returns a list. """ generic_family = generic_family or [] + font_families = font_families or [] def validator(font_value): font_value = ' '.join(font_value.strip().split()) @@ -135,12 +136,17 @@ def validator(font_value): if (val.startswith('"') and val.endswith('"') or val.startswith("'") and val.endswith("'")): - # TODO: Check that the font exists? try: - ast.literal_eval(val) + no_quotes_val = ast.literal_eval(val) checked_values.append(val) except ValueError: raise ValidationError + + if no_quotes_val not in font_families: + print(font_families) + raise ValidationError('Font family "{font_value}"' + ' not found on system!'.format(font_value=no_quotes_val)) + elif val in generic_family: checked_values.append(val) else: diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 237fcd5a0..cff50dca2 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -444,7 +444,7 @@ def test_list_property(self): # Check valid values node.style.font_family = ['caption'] node.style.font_family = ['serif'] - node.style.font_family = ["'Lucida Foo Bar'", 'serif'] + node.style.font_family = ["'Arial Black'", 'serif'] # TODO: This will coerce to a list, is this a valid behavior? node.style.font_family = 'just-a-string' @@ -454,7 +454,7 @@ def test_list_property(self): # Check invalid values with self.assertRaises(ValueError): - node.style.font_family = ['Lucida Foo Bar'] + node.style.font_family = ['Arial Black'] # Check the error message try: @@ -712,9 +712,9 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"Foo Bar Spam"', 'serif'] + node.style.font_family = ['"Arial Black"', 'serif'] # TODO: Is this the behavior we want? - self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "Foo Bar Spam", serif') + self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "Arial Black", serif') # Check setting the shorthand resets values node.style.font = '9px serif' @@ -734,7 +734,7 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"Foo Bar Spam"', 'serif'] + node.style.font_family = ['"Arial Black"', 'serif'] self.assertEqual(node.style.font, '9px serif') # Check invalid values diff --git a/tests/test_fonts.py b/tests/test_fonts.py index c6ba1673a..cd844359d 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -38,13 +38,13 @@ 'line_height': '120%', 'font_family': ['fantasy'], }, - r'x-large/110% "New Century Schoolbook",serif': { + r'x-large/110% "Arial Black",serif': { 'font_style': 'normal', 'font_variant': 'normal', 'font_weight': 'normal', 'font_size': 'x-large', 'line_height': '110%', - 'font_family': ['"New Century Schoolbook"', 'serif'], + 'font_family': ['"Arial Black"', 'serif'], }, } @@ -58,7 +58,7 @@ def test_parse_font_shorthand(self): # Test extra spaces parse_font_property(r' normal normal normal 12px/12px serif ') - parse_font_property(r' normal normal normal 12px/12px "New Foo Bar", serif ') + parse_font_property(r' normal normal normal 12px/12px " Arial Black ", serif ') # Test valid single part for part in SYSTEM_FONT_KEYWORDS: diff --git a/tests/test_validators.py b/tests/test_validators.py index 6f22569f7..19237a5b7 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -44,10 +44,10 @@ def test_number(self): class FontTests(TestCase): def test_font_family_name_valid(self): - validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) - self.assertEqual(validator('"New Century Schoolbook", serif'), ['"New Century Schoolbook"', 'serif']) - self.assertEqual(validator("'21st Century',fantasy"), ["'21st Century'", 'fantasy']) - self.assertEqual(validator(" ' 21st Century ' , fantasy "), ["'21st Century'", 'fantasy']) + validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=['Arial Black']) + self.assertEqual(validator('"Arial Black", serif'), ['"Arial Black"', 'serif']) + self.assertEqual(validator("'Arial Black',fantasy"), ["'Arial Black'", 'fantasy']) + self.assertEqual(validator(" ' Arial Black ' , fantasy "), ["'Arial Black'", 'fantasy']) def test_font_family_name_invalid(self): validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) From 4f8c4b94007156ffc74f6387e85fdfb3b163c38e Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 15:07:12 -0500 Subject: [PATCH 10/32] Update font for linux --- colosseum/constants.py | 18 +++++++++++++++++- colosseum/fonts.py | 2 -- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index 7fd6bba55..90a5c9d4f 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,3 +1,4 @@ +import os import sys from .validators import (ValidationError, is_color, is_font_family, @@ -333,8 +334,15 @@ def __str__(self): def available_font_families(): """List available font family names.""" + # TODO: for tests + if os.environ.get('GITHUB_ACTIONS', None) == 'true': + return ['Arial Black'] + if sys.platform == 'darwin': return _available_font_families_mac() + elif sys.platform.startswith('linux'): + return _available_font_families_unix() + return [] @@ -342,7 +350,7 @@ def _available_font_families_mac(): """List available font family names on mac.""" from ctypes import cdll, util from rubicon.objc import ObjCClass - appkit = cdll.LoadLibrary(util.find_library('AppKit')) + appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa NSFontManager = ObjCClass("NSFontManager") NSFontManager.declare_class_property('sharedFontManager') NSFontManager.declare_class_property("sharedFontManager") @@ -351,6 +359,14 @@ def _available_font_families_mac(): return list(sorted(str(item) for item in manager.availableFontFamilies)) +def _available_font_families_unix(): + """List available font family names on unix.""" + import subprocess + p = subprocess.check_output(['fc-list', ':', 'family']) + fonts = p.decode().split('\n') + return list(sorted(set(fonts))) + + AVAILABLE_FONT_FAMILIES = available_font_families() FONT_FAMILY_CHOICES = Choices( validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=AVAILABLE_FONT_FAMILIES)], diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 9edd1ab2a..f6ea64202 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -1,5 +1,3 @@ -import sys - from .constants import (FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, INHERIT, INITIAL_FONT_VALUES, From 925bf178fcdeea2105874e97cee400e41c7c7faa Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 17:02:33 -0500 Subject: [PATCH 11/32] Update tests --- colosseum/fonts.py | 4 ++-- colosseum/validators.py | 5 +++-- tests/test_declaration.py | 9 ++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index f6ea64202..4e913e203 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -8,9 +8,9 @@ def get_system_font(keyword): """Return a font object from given system font keyword.""" if keyword in SYSTEM_FONT_KEYWORDS: + return '"Arial Black"' # Get the system font - font = INITIAL_FONT_VALUES.copy() - return font + return None def construct_font_property(font): diff --git a/colosseum/validators.py b/colosseum/validators.py index 7b1b41fd2..be7def3f9 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -143,15 +143,16 @@ def validator(font_value): raise ValidationError if no_quotes_val not in font_families: - print(font_families) raise ValidationError('Font family "{font_value}"' ' not found on system!'.format(font_value=no_quotes_val)) elif val in generic_family: checked_values.append(val) else: - # TODO: Check that the font exists? if _CSS_IDENTIFIER_RE.match(val): + if val not in font_families: + raise ValidationError('Font family "{font_value}"' + ' not found on system!'.format(font_value=val)) checked_values.append(val) else: raise ValidationError diff --git a/tests/test_declaration.py b/tests/test_declaration.py index cff50dca2..8d12778cc 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -442,15 +442,14 @@ def test_list_property(self): self.assertEqual(node.style.font_family, ['initial']) # Check valid values - node.style.font_family = ['caption'] node.style.font_family = ['serif'] node.style.font_family = ["'Arial Black'", 'serif'] # TODO: This will coerce to a list, is this a valid behavior? - node.style.font_family = 'just-a-string' - self.assertEqual(node.style.font_family, ['just-a-string']) - node.style.font_family = ' just-a-string , caption ' - self.assertEqual(node.style.font_family, ['just-a-string', 'caption']) + node.style.font_family = '"Arial Black"' + self.assertEqual(node.style.font_family, ['"Arial Black"']) + node.style.font_family = ' " Arial Black " , serif ' + self.assertEqual(node.style.font_family, ['"Arial Black"', 'serif']) # Check invalid values with self.assertRaises(ValueError): From bc376e5b61cbd4739d0b1f47e12dd167e685ca1a Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 3 Jan 2020 20:00:53 -0500 Subject: [PATCH 12/32] Copy ahem font --- .github/workflows/ci.yml | 8 ++++++++ colosseum/constants.py | 9 ++------- tests/fonts/ahem.ttf | Bin 0 -> 22572 bytes tests/test_declaration.py | 18 +++++++++--------- tests/test_fonts.py | 10 +++++----- tests/test_validators.py | 8 ++++---- 6 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 tests/fonts/ahem.ttf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd6ef88bf..0e32531fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 + - name: Copy Ahem font + run: | + mkdir -p ~/.local/share/fonts/ + cp tests/fonts/ahem.ttf ~/.local/share/fonts/ahem.ttf - name: Set up Python 3.7 uses: actions/setup-python@v1 with: @@ -52,6 +56,10 @@ jobs: python-version: [3.5, 3.6] steps: - uses: actions/checkout@v1 + - name: Copy Ahem font + run: | + mkdir -p ~/.local/share/fonts/ + cp tests/fonts/ahem.ttf ~/.local/share/fonts/ahem.ttf - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: diff --git a/colosseum/constants.py b/colosseum/constants.py index 90a5c9d4f..396632624 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,4 +1,3 @@ -import os import sys from .validators import (ValidationError, is_color, is_font_family, @@ -334,10 +333,6 @@ def __str__(self): def available_font_families(): """List available font family names.""" - # TODO: for tests - if os.environ.get('GITHUB_ACTIONS', None) == 'true': - return ['Arial Black'] - if sys.platform == 'darwin': return _available_font_families_mac() elif sys.platform.startswith('linux'): @@ -356,7 +351,7 @@ def _available_font_families_mac(): NSFontManager.declare_class_property("sharedFontManager") NSFontManager.declare_property("availableFontFamilies") manager = NSFontManager.sharedFontManager - return list(sorted(str(item) for item in manager.availableFontFamilies)) + return list(sorted(str(item) for item in manager.availableFontFamilies if item)) def _available_font_families_unix(): @@ -364,7 +359,7 @@ def _available_font_families_unix(): import subprocess p = subprocess.check_output(['fc-list', ':', 'family']) fonts = p.decode().split('\n') - return list(sorted(set(fonts))) + return list(sorted(set(item for item in fonts if item))) AVAILABLE_FONT_FAMILIES = available_font_families() diff --git a/tests/fonts/ahem.ttf b/tests/fonts/ahem.ttf new file mode 100644 index 0000000000000000000000000000000000000000..306f065ef5bf1101ec70cca49ca187427d898cf1 GIT binary patch literal 22572 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 literal 0 HcmV?d00001 diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 8d12778cc..dd80244e7 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -443,17 +443,17 @@ def test_list_property(self): # Check valid values node.style.font_family = ['serif'] - node.style.font_family = ["'Arial Black'", 'serif'] + node.style.font_family = ["'DejaVu Sans'", 'serif'] # TODO: This will coerce to a list, is this a valid behavior? - node.style.font_family = '"Arial Black"' - self.assertEqual(node.style.font_family, ['"Arial Black"']) - node.style.font_family = ' " Arial Black " , serif ' - self.assertEqual(node.style.font_family, ['"Arial Black"', 'serif']) + node.style.font_family = '"DejaVu Sans"' + self.assertEqual(node.style.font_family, ['"DejaVu Sans"']) + node.style.font_family = ' " DejaVu Sans " , serif ' + self.assertEqual(node.style.font_family, ['"DejaVu Sans"', 'serif']) # Check invalid values with self.assertRaises(ValueError): - node.style.font_family = ['Arial Black'] + node.style.font_family = ['DejaVu Sans'] # Check the error message try: @@ -711,9 +711,9 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"Arial Black"', 'serif'] + node.style.font_family = ['"DejaVu Sans"', 'serif'] # TODO: Is this the behavior we want? - self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "Arial Black", serif') + self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "DejaVu Sans", serif') # Check setting the shorthand resets values node.style.font = '9px serif' @@ -733,7 +733,7 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"Arial Black"', 'serif'] + node.style.font_family = ['"DejaVu Sans"', 'serif'] self.assertEqual(node.style.font, '9px serif') # Check invalid values diff --git a/tests/test_fonts.py b/tests/test_fonts.py index cd844359d..544300883 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -22,13 +22,13 @@ 'line_height': 'normal', 'font_family': ['sans-serif'], }, - r'bold italic large Palatino, serif': { + r'bold italic large Ahem, serif': { 'font_style': 'italic', 'font_variant': 'normal', 'font_weight': 'bold', 'font_size': 'large', 'line_height': 'normal', - 'font_family': ['Palatino', 'serif'], + 'font_family': ['Ahem', 'serif'], }, r'normal small-caps 120%/120% fantasy': { 'font_style': 'normal', @@ -38,13 +38,13 @@ 'line_height': '120%', 'font_family': ['fantasy'], }, - r'x-large/110% "Arial Black",serif': { + r'x-large/110% "DejaVu Sans",serif': { 'font_style': 'normal', 'font_variant': 'normal', 'font_weight': 'normal', 'font_size': 'x-large', 'line_height': '110%', - 'font_family': ['"Arial Black"', 'serif'], + 'font_family': ['"DejaVu Sans"', 'serif'], }, } @@ -58,7 +58,7 @@ def test_parse_font_shorthand(self): # Test extra spaces parse_font_property(r' normal normal normal 12px/12px serif ') - parse_font_property(r' normal normal normal 12px/12px " Arial Black ", serif ') + parse_font_property(r' normal normal normal 12px/12px " DejaVu Sans ", serif ') # Test valid single part for part in SYSTEM_FONT_KEYWORDS: diff --git a/tests/test_validators.py b/tests/test_validators.py index 19237a5b7..1edaf654f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -44,10 +44,10 @@ def test_number(self): class FontTests(TestCase): def test_font_family_name_valid(self): - validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=['Arial Black']) - self.assertEqual(validator('"Arial Black", serif'), ['"Arial Black"', 'serif']) - self.assertEqual(validator("'Arial Black',fantasy"), ["'Arial Black'", 'fantasy']) - self.assertEqual(validator(" ' Arial Black ' , fantasy "), ["'Arial Black'", 'fantasy']) + validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=['DejaVu Sans']) + self.assertEqual(validator('"DejaVu Sans", serif'), ['"DejaVu Sans"', 'serif']) + self.assertEqual(validator("'DejaVu Sans',fantasy"), ["'DejaVu Sans'", 'fantasy']) + self.assertEqual(validator(" ' DejaVu Sans ' , fantasy "), ["'DejaVu Sans'", 'fantasy']) def test_font_family_name_invalid(self): validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) From 71eb7894d9ac7e8ef711d856bb219d1a1d969c61 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Sat, 4 Jan 2020 13:34:18 -0500 Subject: [PATCH 13/32] Add win font query --- colosseum/constants.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/colosseum/constants.py b/colosseum/constants.py index 396632624..b2769343b 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,3 +1,4 @@ +import os import sys from .validators import (ValidationError, is_color, is_font_family, @@ -337,6 +338,8 @@ def available_font_families(): return _available_font_families_mac() elif sys.platform.startswith('linux'): return _available_font_families_unix() + elif os.name == 'nt': + return _available_font_families_win() return [] @@ -362,6 +365,21 @@ def _available_font_families_unix(): return list(sorted(set(item for item in fonts if item))) +def _available_font_families_win(): + """List available font family names on windows.""" + import winreg + key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", + 0, + winreg.KEY_READ) + fonts = set() + for idx in range(0, winreg.QueryInfoKey(key)[1]): + font_name = winreg.EnumValue(key, idx)[0] + font_name = font_name.replace(' (TrueType)', '') + fonts.add(font_name) + return list(sorted(fonts)) + + AVAILABLE_FONT_FAMILIES = available_font_families() FONT_FAMILY_CHOICES = Choices( validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=AVAILABLE_FONT_FAMILIES)], From ba7de679347ff0ce542fd672661c939240c67f43 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 8 Jan 2020 15:38:23 -0500 Subject: [PATCH 14/32] Refactor code, cache fonts and address code review comments --- colosseum/constants.py | 66 ++------------- colosseum/declaration.py | 32 ++++---- colosseum/exceptions.py | 3 + colosseum/fonts.py | 169 +++++++++++++++++++------------------- colosseum/parser.py | 115 ++++++++++++++++++++++++++ colosseum/units.py | 8 +- colosseum/validators.py | 108 +++++++++++------------- tests/test_declaration.py | 61 +++++++++----- tests/test_fonts.py | 21 ++--- tests/test_parser.py | 63 ++++++++++++++ tests/test_validators.py | 15 ++-- 11 files changed, 405 insertions(+), 256 deletions(-) create mode 100644 colosseum/exceptions.py diff --git a/colosseum/constants.py b/colosseum/constants.py index b2769343b..ba7a8f6e9 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,9 +1,5 @@ -import os -import sys - -from .validators import (ValidationError, is_color, is_font_family, - is_integer, is_length, - is_number, is_percentage) +from .exceptions import ValidationError +from .validators import is_color, is_font_family, is_integer, is_length, is_number, is_percentage class Choices: @@ -38,13 +34,13 @@ def validate(self, value): raise ValueError() def __str__(self): - choices = [str(c).lower().replace('_', '-') for c in self.constants] + choices = set([str(c).lower().replace('_', '-') for c in self.constants]) for validator in self.validators: - choices += validator.description.split(', ') + choices.update(validator.description.split(', ')) if self.explicit_defaulting_constants: for item in self.explicit_defaulting_constants: - choices.append(item) + choices.add(item) return ", ".join(sorted(set(choices))) @@ -331,58 +327,8 @@ def __str__(self): GENERIC_FAMILY_FONTS = [SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE] - -def available_font_families(): - """List available font family names.""" - if sys.platform == 'darwin': - return _available_font_families_mac() - elif sys.platform.startswith('linux'): - return _available_font_families_unix() - elif os.name == 'nt': - return _available_font_families_win() - - return [] - - -def _available_font_families_mac(): - """List available font family names on mac.""" - from ctypes import cdll, util - from rubicon.objc import ObjCClass - appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa - NSFontManager = ObjCClass("NSFontManager") - NSFontManager.declare_class_property('sharedFontManager') - NSFontManager.declare_class_property("sharedFontManager") - NSFontManager.declare_property("availableFontFamilies") - manager = NSFontManager.sharedFontManager - return list(sorted(str(item) for item in manager.availableFontFamilies if item)) - - -def _available_font_families_unix(): - """List available font family names on unix.""" - import subprocess - p = subprocess.check_output(['fc-list', ':', 'family']) - fonts = p.decode().split('\n') - return list(sorted(set(item for item in fonts if item))) - - -def _available_font_families_win(): - """List available font family names on windows.""" - import winreg - key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, - r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", - 0, - winreg.KEY_READ) - fonts = set() - for idx in range(0, winreg.QueryInfoKey(key)[1]): - font_name = winreg.EnumValue(key, idx)[0] - font_name = font_name.replace(' (TrueType)', '') - fonts.add(font_name) - return list(sorted(fonts)) - - -AVAILABLE_FONT_FAMILIES = available_font_families() FONT_FAMILY_CHOICES = Choices( - validators=[is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=AVAILABLE_FONT_FAMILIES)], + validators=[is_font_family], explicit_defaulting_constants=[INHERIT, INITIAL], ) diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 56d934374..ae6f0e853 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -5,19 +5,19 @@ BORDER_WIDTH_CHOICES, BOX_OFFSET_CHOICES, CLEAR_CHOICES, COLOR_CHOICES, DIRECTION_CHOICES, DISPLAY_CHOICES, FLEX_BASIS_CHOICES, FLEX_DIRECTION_CHOICES, FLEX_GROW_CHOICES, FLEX_SHRINK_CHOICES, FLEX_START, - FLEX_WRAP_CHOICES, FLOAT_CHOICES, GRID_AUTO_CHOICES, - GRID_AUTO_FLOW_CHOICES, GRID_GAP_CHOICES, GRID_PLACEMENT_CHOICES, - GRID_TEMPLATE_AREA_CHOICES, GRID_TEMPLATE_CHOICES, INLINE, - JUSTIFY_CONTENT_CHOICES, LTR, MARGIN_CHOICES, MAX_SIZE_CHOICES, + FLEX_WRAP_CHOICES, FLOAT_CHOICES, FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, + FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, + GRID_AUTO_CHOICES, GRID_AUTO_FLOW_CHOICES, GRID_GAP_CHOICES, + GRID_PLACEMENT_CHOICES, GRID_TEMPLATE_AREA_CHOICES, GRID_TEMPLATE_CHOICES, + INITIAL, INITIAL_FONT_VALUES, INLINE, JUSTIFY_CONTENT_CHOICES, + LINE_HEIGHT_CHOICES, LTR, MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, MIN_SIZE_CHOICES, NORMAL, NOWRAP, ORDER_CHOICES, PADDING_CHOICES, - POSITION_CHOICES, ROW, SIZE_CHOICES, STATIC, STRETCH, - TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, VISIBLE, - Z_INDEX_CHOICES, default, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, - FONT_WEIGHT_CHOICES, FONT_SIZE_CHOICES, MEDIUM, FONT_FAMILY_CHOICES, INITIAL, - INITIAL_FONT_VALUES, LINE_HEIGHT_CHOICES, + POSITION_CHOICES, ROW, SIZE_CHOICES, STATIC, STRETCH, TRANSPARENT, + UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, VISIBLE, Z_INDEX_CHOICES, + default, ) -from .fonts import construct_font_property, parse_font_property - +from .exceptions import ValidationError +from .parser import construct_font, parse_font _CSS_PROPERTIES = set() @@ -28,13 +28,17 @@ def validated_font_property(name, initial): initial = initial.copy() def getter(self): - font = initial + font = initial.copy() for property_name in font: font[property_name] = getattr(self, property_name) - return getattr(self, '_%s' % name, construct_font_property(font)) + return font def setter(self, value): - font = parse_font_property(value) + try: + font = parse_font(value) + except ValidationError: + raise ValueError("Invalid value '%s' for CSS property '%s'!" % (value, name)) + for property_name, property_value in font.items(): setattr(self, property_name, property_value) self.dirty = True diff --git a/colosseum/exceptions.py b/colosseum/exceptions.py new file mode 100644 index 000000000..b756bacde --- /dev/null +++ b/colosseum/exceptions.py @@ -0,0 +1,3 @@ + +class ValidationError(Exception): + pass diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 4e913e203..10ff2318b 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -1,95 +1,96 @@ -from .constants import (FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, - FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, - FONT_WEIGHT_CHOICES, INHERIT, INITIAL_FONT_VALUES, - NORMAL, SYSTEM_FONT_KEYWORDS) -from .validators import ValidationError +"""Font utilities.""" +import os +import sys -def get_system_font(keyword): - """Return a font object from given system font keyword.""" - if keyword in SYSTEM_FONT_KEYWORDS: - return '"Arial Black"' - # Get the system font - return None - - -def construct_font_property(font): - """Construct font property string from a dictionary of font properties.""" - if isinstance(font['font_family'], list): - font['font_family'] = ', '.join(font['font_family']) - - return ('{font_style} {font_variant} {font_weight} ' - '{font_size}/{line_height} {font_family}').format(**font) - - -def parse_font_property_part(value, font): - """Parse font shorthand property part for known properties.""" - if value != NORMAL: - for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, - 'font_weight': FONT_WEIGHT_CHOICES, - 'font_style': FONT_STYLE_CHOICES}.items(): - try: - value = choices.validate(value) - font[property_name] = value - return font - except (ValidationError, ValueError): - pass - - # Maybe it is a font size - if '/' in value: - font['font_size'], font['line_height'] = value.split('/') - return font - else: - try: - FONT_SIZE_CHOICES.validate(value) - font['font_size'] = value - return font - except ValueError: - pass - - raise ValidationError - - return font +from .exceptions import ValidationError -def parse_font_property(string): +class FontDatabase: """ - Parse font string into a dictionary of font properties. - - Reference: - - https://www.w3.org/TR/CSS21/fonts.html#font-shorthand - - https://developer.mozilla.org/en-US/docs/Web/CSS/font + Provide information about the fonts available in the underlying system. """ - font = INITIAL_FONT_VALUES.copy() + _FONTS_CACHE = {} - # Remove extra spaces - string = ' '.join(string.strip().split()) + @classmethod + def validate_font_family(cls, value): + """ + Validate a font family with the system found fonts. - parts = string.split(' ', 1) - if len(parts) == 1: - value = parts[0] - if value == INHERIT: - # TODO: ?? - pass - else: - if value not in SYSTEM_FONT_KEYWORDS: - error_msg = ('Font property value "{value}" ' - 'not a system font keyword!'.format(value=value)) - raise ValidationError(error_msg) - font = get_system_font(value) - else: - for _ in range(5): - value = parts[0] - try: - font = parse_font_property_part(value, font) - parts = parts[-1].split(' ', 1) - except ValidationError: - break + Found fonts are cached for future usage. + """ + if value in cls._FONTS_CACHE: + return value else: - # Font family can have a maximum of 4 parts before the font_family part - raise ValidationError('Font property shorthand contains too many parts!') + if check_font_family(value): + # TODO: to be filled with a font properties instance + cls._FONTS_CACHE[value] = None + return value + + raise ValidationError('Font family "{value}" not found on system!'.format(value=value)) + + +def _check_font_family_mac(value): + """List available font family names on mac.""" + from ctypes import cdll, util + from rubicon.objc import ObjCClass + appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa + NSFontManager = ObjCClass("NSFontManager") + NSFontManager.declare_class_property('sharedFontManager') + NSFontManager.declare_property("availableFontFamilies") + manager = NSFontManager.sharedFontManager + for item in manager.availableFontFamilies: + font_name = str(item) + if font_name == value: + return True + + return False + + +def _check_font_family_unix(value): + """List available font family names on unix.""" + import subprocess + proc = subprocess.check_output(['fc-list', ':', 'family']) + fonts = proc.decode().split('\n') + for font_name in fonts: + if font_name == value: + return True + + return False + + +def _check_font_family_win(value): + """List available font family names on windows.""" + import winreg + key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", + 0, + winreg.KEY_READ) + for idx in range(0, winreg.QueryInfoKey(key)[1]): + font_name = winreg.EnumValue(key, idx)[0] + font_name = font_name.replace(' (TrueType)', '') + if font_name == value: + return True + + return False + + +def check_font_family(value): + """List available font family names.""" + if sys.platform == 'darwin': + return _check_font_family_mac(value) + elif sys.platform.startswith('linux'): + return _check_font_family_unix(value) + elif os.name == 'nt': + return _check_font_family_mac(value) + - value = ' '.join(parts) - font['font_family'] = FONT_FAMILY_CHOICES.validate(value) +def get_system_font(keyword): + """Return a font object from given system font keyword.""" + from .constants import SYSTEM_FONT_KEYWORDS - return font + if keyword in SYSTEM_FONT_KEYWORDS: + # Get the system font + return 'Ahem' + + return None diff --git a/colosseum/parser.py b/colosseum/parser.py index 371dd2ae8..558bd0d83 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -1,4 +1,6 @@ from .colors import NAMED_COLOR, hsl, rgb +from .exceptions import ValidationError +from .fonts import get_system_font from .units import Unit, px @@ -126,3 +128,116 @@ def color(value): pass raise ValueError('Unknown color %s' % value) + + + +############################################################################## +# # Font handling +############################################################################## +def _parse_font_property_part(value, font_dict): + """Parse font shorthand property part for known properties.""" + from .constants import (FONT_SIZE_CHOICES, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, + FONT_WEIGHT_CHOICES, LINE_HEIGHT_CHOICES, NORMAL) + font_dict = font_dict.copy() + if value != NORMAL: + for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, + 'font_weight': FONT_WEIGHT_CHOICES, + 'font_style': FONT_STYLE_CHOICES}.items(): + try: + value = choices.validate(value) + font_dict[property_name] = value + return font_dict + except (ValidationError, ValueError): + pass + + # Maybe it is a font size + if '/' in value: + font_dict['font_size'], font_dict['line_height'] = value.split('/') + FONT_SIZE_CHOICES.validate(font_dict['font_size']) + LINE_HEIGHT_CHOICES.validate(font_dict['line_height']) + return font_dict + else: + try: + FONT_SIZE_CHOICES.validate(value) + font_dict['font_size'] = value + return font_dict + except ValueError: + pass + + raise ValidationError('Font value "{value}" not valid!'.format(value=value)) + + return font_dict + + +def parse_font(string): + """ + Parse font string into a dictionary of font properties. + + The font CSS property is a shorthand for font-style, font-variant, font-weight, + font-size, line-height, and font-family. + + Alternatively, it sets an element's font to a system font. + + Reference: + - https://www.w3.org/TR/CSS21/fonts.html#font-shorthand + - https://developer.mozilla.org/en-US/docs/Web/CSS/font + """ + from .constants import INHERIT, INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS, FONT_FAMILY_CHOICES # noqa + font_dict = INITIAL_FONT_VALUES.copy() + + # Remove extra spaces + string = ' '.join(string.strip().split()) + + parts = string.split(' ', 1) + if len(parts) == 1: + # If font is specified as a system keyword, it must be one of: + # caption, icon, menu, message-box, small-caption, status-bar + value = parts[0] + if value == INHERIT: + # TODO: To be completed by future work + pass + else: + if value not in SYSTEM_FONT_KEYWORDS: + error_msg = ('Font property value "{value}" ' + 'not a system font keyword!'.format(value=value)) + raise ValidationError(error_msg) + font_dict = get_system_font(value) + else: + # If font is specified as a shorthand for several font-related properties, then: + # - It must include values for: + # and + # - It may optionally include values for: + # and + # - font-style, font-variant and font-weight must precede font-size + # - font-variant may only specify the values defined in CSS 2.1 + # - line-height must immediately follow font-size, preceded by "/", like this: "16px/3" + # - font-family must be the last value specified. + + # We iteratively split by the first left hand space found and try to validate if that part + # is a valid or or (which can come in any order) + # or / (which has to come after all the other properties) + for _ in range(5): + value = parts[0] + try: + font_dict = _parse_font_property_part(value, font_dict) + parts = parts[-1].split(' ', 1) + except ValidationError: + break + else: + # Font family can have a maximum of 4 parts before the font_family part. + # / + raise ValidationError('Font property shorthand contains too many parts!') + + value = ' '.join(parts) + font_dict['font_family'] = FONT_FAMILY_CHOICES.validate(value) + + return font_dict + + +def construct_font(font_dict): + """Construct font property string from a dictionary of font properties.""" + if isinstance(font_dict['font_family'], list): + font_dict['font_family'] = ', '.join(font_dict['font_family']) + + return ('{font_style} {font_variant} {font_weight} ' + '{font_size}/{line_height} {font_family}').format(**font_dict) diff --git a/colosseum/units.py b/colosseum/units.py index 1bc212b3d..b6fe78d47 100644 --- a/colosseum/units.py +++ b/colosseum/units.py @@ -18,10 +18,14 @@ def __init__(self, suffix, val=None): self.val = val if val is not None else 1 def __repr__(self): - return '{}{}'.format(self.val, self.suffix) + int_value = int(self.val) + value = int_value if self.val == int_value else self.val + return '{}{}'.format(value, self.suffix) def __str__(self): - return '{}{}'.format(self.val, self.suffix) + int_value = int(self.val) + value = int_value if self.val == int_value else self.val + return '{}{}'.format(value, self.suffix) def __rmul__(self, val): if isinstance(val, (int, float)): diff --git a/colosseum/validators.py b/colosseum/validators.py index be7def3f9..e9b584991 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -1,12 +1,10 @@ import ast import re +from . import exceptions from . import parser from . import units - - -class ValidationError(ValueError): - pass +from . import fonts def _numeric_validator(num_value, numeric_type, min_value, max_value): @@ -15,17 +13,17 @@ def _numeric_validator(num_value, numeric_type, min_value, max_value): except (ValueError, TypeError): error_msg = "Cannot coerce {num_value} to {numeric_type}".format( num_value=num_value, numeric_type=numeric_type.__name__) - raise ValidationError(error_msg) + raise exceptions.ValidationError(error_msg) if min_value is not None and num_value < min_value: error_msg = 'Value {num_value} below minimum value {min_value}'.format( num_value=num_value, min_value=min_value) - raise ValidationError(error_msg) + raise exceptions.ValidationError(error_msg) if max_value is not None and num_value > max_value: error_msg = 'Value {num_value} above maximum value {max_value}'.format( num_value=num_value, max_value=max_value) - raise ValidationError(error_msg) + raise exceptions.ValidationError(error_msg) return num_value @@ -74,7 +72,7 @@ def is_length(value): try: value = parser.units(value) except ValueError as error: - raise ValidationError(str(error)) + raise exceptions.ValidationError(str(error)) return value @@ -86,11 +84,11 @@ def is_percentage(value): try: value = parser.units(value) except ValueError as error: - raise ValidationError(str(error)) + raise exceptions.ValidationError(str(error)) if not isinstance(value, units.Percent): error_msg = 'Value {value} is not a Percent unit'.format(value=value) - raise ValidationError(error_msg) + raise exceptions.ValidationError(error_msg) return value @@ -102,7 +100,7 @@ def is_color(value): try: value = parser.color(value) except ValueError as error: - raise ValidationError(str(error)) + raise exceptions.ValidationError(str(error)) return value @@ -114,61 +112,53 @@ def is_color(value): _CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') -def is_font_family(value=None, generic_family=None, font_families=None): +def is_font_family(value): """ Validate that value is a valid font family. This validator returns a list. """ - generic_family = generic_family or [] - font_families = font_families or [] - - def validator(font_value): - font_value = ' '.join(font_value.strip().split()) - values = [v.strip() for v in font_value.split(',')] - checked_values = [] - for val in values: - # Remove extra inner spaces - val = val.replace('" ', '"') - val = val.replace(' "', '"') - val = val.replace("' ", "'") - val = val.replace(" '", "'") - - if (val.startswith('"') and val.endswith('"') - or val.startswith("'") and val.endswith("'")): - try: - no_quotes_val = ast.literal_eval(val) - checked_values.append(val) - except ValueError: - raise ValidationError - - if no_quotes_val not in font_families: - raise ValidationError('Font family "{font_value}"' - ' not found on system!'.format(font_value=no_quotes_val)) - - elif val in generic_family: + from .constants import GENERIC_FAMILY_FONTS as generic_family + font_database = fonts.FontDatabase + font_value = ' '.join(value.strip().split()) + values = [v.strip() for v in font_value.split(',')] + checked_values = [] + for val in values: + # Remove extra inner spaces + val = val.replace('" ', '"') + val = val.replace(' "', '"') + val = val.replace("' ", "'") + val = val.replace(" '", "'") + + if (val.startswith('"') and val.endswith('"') + or val.startswith("'") and val.endswith("'")): + try: + no_quotes_val = ast.literal_eval(val) + checked_values.append(val) + except ValueError: + raise exceptions.ValidationError + + if not font_database.validate_font_family(no_quotes_val): + raise exceptions.ValidationError('Font family "{font_value}"' + ' not found on system!'.format(font_value=no_quotes_val)) + + elif val in generic_family: + checked_values.append(val) + else: + error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=val) + if _CSS_IDENTIFIER_RE.match(val): + if not font_database.validate_font_family(val): + raise exceptions.ValidationError(error_msg) checked_values.append(val) else: - if _CSS_IDENTIFIER_RE.match(val): - if val not in font_families: - raise ValidationError('Font family "{font_value}"' - ' not found on system!'.format(font_value=val)) - checked_values.append(val) - else: - raise ValidationError - - if len(checked_values) != len(values): - invalid = set(values) - set(checked_values) - error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) - raise ValidationError(error_msg) - - return checked_values - - if generic_family is []: - return validator(value) - else: - validator.description = ', ' - return validator + raise exceptions.ValidationError(error_msg) + + if len(checked_values) != len(values): + invalid = set(values) - set(checked_values) + error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) + raise exceptions.ValidationError(error_msg) + + return checked_values is_font_family.description = ', ' diff --git a/tests/test_declaration.py b/tests/test_declaration.py index dd80244e7..ec656ac89 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -2,10 +2,15 @@ from colosseum import engine as css_engine from colosseum.colors import GOLDENROD, NAMED_COLOR, REBECCAPURPLE -from colosseum.constants import AUTO, BLOCK, INLINE, TABLE, Choices, INITIAL, INHERIT, UNSET, REVERT +from colosseum.constants import ( + AUTO, BLOCK, INHERIT, INITIAL, INITIAL_FONT_VALUES, INLINE, REVERT, TABLE, + UNSET, Choices, +) from colosseum.declaration import CSS, validated_property from colosseum.units import percent, px -from colosseum.validators import is_color, is_integer, is_length, is_number, is_percentage +from colosseum.validators import ( + is_color, is_integer, is_length, is_number, is_percentage, +) from .utils import TestNode @@ -443,18 +448,18 @@ def test_list_property(self): # Check valid values node.style.font_family = ['serif'] - node.style.font_family = ["'DejaVu Sans'", 'serif'] + node.style.font_family = ["Ahem", 'serif'] # TODO: This will coerce to a list, is this a valid behavior? - node.style.font_family = '"DejaVu Sans"' - self.assertEqual(node.style.font_family, ['"DejaVu Sans"']) - node.style.font_family = ' " DejaVu Sans " , serif ' - self.assertEqual(node.style.font_family, ['"DejaVu Sans"', 'serif']) + node.style.font_family = 'Ahem' + self.assertEqual(node.style.font_family, ['Ahem']) + node.style.font_family = ' Ahem , serif ' + self.assertEqual(node.style.font_family, ['Ahem', 'serif']) # Check invalid values with self.assertRaises(ValueError): - node.style.font_family = ['DejaVu Sans'] - + node.style.font_family = ['DejaVu Sans'] # Should have additional quotes + # Check the error message try: node.style.font_family = ['123'] @@ -695,7 +700,7 @@ def test_font_shorthand_property(self): node.layout.dirty = None # Check initial value - self.assertEqual(node.style.font, 'normal normal normal medium/normal initial') + self.assertEqual(node.style.font, INITIAL_FONT_VALUES) # Check Initial values self.assertEqual(node.style.font_style, 'normal') @@ -711,9 +716,19 @@ def test_font_shorthand_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"DejaVu Sans"', 'serif'] - # TODO: Is this the behavior we want? - self.assertEqual(node.style.font, 'italic small-caps bold 10.0px/1.5 "DejaVu Sans", serif') + node.style.font_family = ['Ahem', 'serif'] + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': ['Ahem', 'serif'], + } + font = node.style.font + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) # Check setting the shorthand resets values node.style.font = '9px serif' @@ -721,21 +736,29 @@ def test_font_shorthand_property(self): self.assertEqual(node.style.font_weight, 'normal') self.assertEqual(node.style.font_variant, 'normal') self.assertEqual(node.style.line_height, 'normal') - self.assertEqual(str(node.style.font_size), '9.0px') + self.assertEqual(str(node.style.font_size), '9px') self.assertEqual(node.style.font_family, ['serif']) - self.assertEqual(node.style.font, '9px serif') # Check individual properties do not update the set shorthand - # TODO: Is this the behavior we want? node.style.font = '9px serif' node.style.font_style = 'italic' node.style.font_weight = 'bold' node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['"DejaVu Sans"', 'serif'] - self.assertEqual(node.style.font, '9px serif') + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': ['serif'], + } + font = node.style.font + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) # Check invalid values with self.assertRaises(ValueError): - node.style.font = 'foobar' + node.style.font = 'ThisIsDefinitelyNotAFontName' diff --git a/tests/test_fonts.py b/tests/test_fonts.py index 544300883..2276c8d7a 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -1,8 +1,8 @@ from unittest import TestCase from colosseum.constants import SYSTEM_FONT_KEYWORDS -from colosseum.fonts import parse_font_property -from colosseum.validators import ValidationError +from colosseum.parser import parse_font +from colosseum.exceptions import ValidationError FONT_CASES = { @@ -38,38 +38,39 @@ 'line_height': '120%', 'font_family': ['fantasy'], }, - r'x-large/110% "DejaVu Sans",serif': { + r'x-large/110% Ahem,serif': { 'font_style': 'normal', 'font_variant': 'normal', 'font_weight': 'normal', 'font_size': 'x-large', 'line_height': '110%', - 'font_family': ['"DejaVu Sans"', 'serif'], + 'font_family': ['Ahem', 'serif'], }, } class FontTests(TestCase): + def test_parse_font_shorthand(self): for case in sorted(FONT_CASES): expected_output = FONT_CASES[case] - font = parse_font_property(case) + font = parse_font(case) self.assertEqual(font, expected_output) # Test extra spaces - parse_font_property(r' normal normal normal 12px/12px serif ') - parse_font_property(r' normal normal normal 12px/12px " DejaVu Sans ", serif ') + parse_font(r' normal normal normal 12px/12px serif ') + parse_font(r' normal normal normal 12px/12px Ahem , serif ') # Test valid single part for part in SYSTEM_FONT_KEYWORDS: - parse_font_property(part) + parse_font(part) def test_parse_font_shorthand_invalid(self): # This font string has too many parts with self.assertRaises(ValidationError): - parse_font_property(r'normal normal normal normal 12px/12px serif') + parse_font(r'normal normal normal normal 12px/12px serif') # This invalid single part for part in ['normal', 'foobar']: with self.assertRaises(ValidationError): - parse_font_property(part) + parse_font(part) diff --git a/tests/test_parser.py b/tests/test_parser.py index b0303d7b6..4523ba021 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -187,3 +187,66 @@ def test_named_color(self): with self.assertRaises(ValueError): parser.color('not a color') + + +class ParseFontTests(TestCase): + + def test_parse_font_part(self): + pass + # - / + # - / + # - / + # - / + + + def test_parse_font(self): + pass + # 5 parts with line height + # - / + # - / + # - / + # - / + # - / + # - / + + # 5 parts + # - + # - + # - + # - + # - + # - + + # 4 parts with height + # - / + # - / + # - / + # - / + # - / + # - / + + # 4 parts + # - + # - + # - + # - + # - + # - + + # 3 parts with height + # - / + # - / + # - / + + # 3 parts with height + # - + # - + # - + + # 2 parts with height + # - / + + # 2 parts + # - + + # 1 part diff --git a/tests/test_validators.py b/tests/test_validators.py index 1edaf654f..f185b250b 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,8 +1,8 @@ from unittest import TestCase from colosseum.constants import GENERIC_FAMILY_FONTS -from colosseum.validators import (ValidationError, is_font_family, - is_integer, is_number) +from colosseum.exceptions import ValidationError +from colosseum.validators import is_font_family, is_integer, is_number class NumericTests(TestCase): @@ -43,14 +43,13 @@ def test_number(self): class FontTests(TestCase): + def test_font_family_name_valid(self): - validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS, font_families=['DejaVu Sans']) - self.assertEqual(validator('"DejaVu Sans", serif'), ['"DejaVu Sans"', 'serif']) - self.assertEqual(validator("'DejaVu Sans',fantasy"), ["'DejaVu Sans'", 'fantasy']) - self.assertEqual(validator(" ' DejaVu Sans ' , fantasy "), ["'DejaVu Sans'", 'fantasy']) + self.assertEqual(is_font_family('Ahem, serif'), ['Ahem', 'serif']) + self.assertEqual(is_font_family("Ahem,fantasy"), ["Ahem", 'fantasy']) + self.assertEqual(is_font_family(" Ahem , fantasy "), ["Ahem", 'fantasy']) def test_font_family_name_invalid(self): - validator = is_font_family(generic_family=GENERIC_FAMILY_FONTS) invalid_cases = [ 'Red/Black, sans-serif', '"Lucida" Grande, sans-serif', @@ -62,4 +61,4 @@ def test_font_family_name_invalid(self): ] for case in invalid_cases: with self.assertRaises(ValidationError): - validator(case) + is_font_family(case) From d496261b9564b844768f5ad6268dda1e5eafa59c Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 8 Jan 2020 17:52:39 -0500 Subject: [PATCH 15/32] Add fontdb and refactor code. Fix code style --- colosseum/declaration.py | 31 ++++++++-------- colosseum/fonts.py | 45 +++++++++++++++-------- colosseum/parser.py | 3 +- colosseum/validators.py | 3 +- tests/test_declaration.py | 15 +++++--- tests/test_fonts.py | 76 -------------------------------------- tests/test_parser.py | 77 ++++++++++++++++++++++++++++++++++++--- tests/test_validators.py | 1 - 8 files changed, 129 insertions(+), 122 deletions(-) diff --git a/colosseum/declaration.py b/colosseum/declaration.py index ae6f0e853..c25d05f4a 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -70,24 +70,22 @@ def validated_list_property(name, choices, initial, separator=','): raise ValueError('Initial value must be a list!') def getter(self): - # TODO: A copy is returned so if the user mutates it, - # it will not affect the stored value - return getattr(self, '_%s' % name, initial)[:] + return getattr(self, '_%s' % name, initial).copy() def setter(self, value): + if not isinstance(value, str): + value = separator.join(value) try: - if not isinstance(value, str): - value = separator.join(value) - # This should be a list of values values = choices.validate(value) - if not isinstance(values, list): - values.split(separator) except ValueError: raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( value, name, choices )) + if not isinstance(values, list): + values.split(separator) + if values != getattr(self, '_%s' % name, initial): setattr(self, '_%s' % name, values[:]) self.dirty = True @@ -590,13 +588,16 @@ def keys(self): def __str__(self): non_default = [] for name in _CSS_PROPERTIES: - try: - non_default.append(( - name.replace('_', '-'), - getattr(self, '_%s' % name) - )) - except AttributeError: - pass + if name == 'font': + non_default.append((name, construct_font(getattr(self, name)))) + else: + try: + non_default.append(( + name.replace('_', '-'), + getattr(self, '_%s' % name) + )) + except AttributeError: + pass return "; ".join( "%s: %s" % (name, value) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 10ff2318b..b3df67f22 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -5,6 +5,9 @@ from .exceptions import ValidationError +# Constants +_GTK_WINDOW = None + class FontDatabase: """ @@ -14,11 +17,7 @@ class FontDatabase: @classmethod def validate_font_family(cls, value): - """ - Validate a font family with the system found fonts. - - Found fonts are cached for future usage. - """ + """Validate a font family with the system found fonts.""" if value in cls._FONTS_CACHE: return value else: @@ -47,16 +46,28 @@ def _check_font_family_mac(value): return False -def _check_font_family_unix(value): - """List available font family names on unix.""" - import subprocess - proc = subprocess.check_output(['fc-list', ':', 'family']) - fonts = proc.decode().split('\n') - for font_name in fonts: - if font_name == value: - return True +def _check_font_family_linux(value): + """List available font family names on linux.""" + import gi # noqa + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk # noqa - return False + class Window(Gtk.Window): + """Use Pango to get system fonts names.""" + + def check_system_font(self, value): + """Check if font family exists on system.""" + context = self.create_pango_context() + for fam in context.list_families(): + if fam.get_name() == value: + return True + return False + + global _GTK_WINDOW # noqa + if _GTK_WINDOW is None: + _GTK_WINDOW = Window() + + return _GTK_WINDOW.check_system_font(value) def _check_font_family_win(value): @@ -80,14 +91,16 @@ def check_font_family(value): if sys.platform == 'darwin': return _check_font_family_mac(value) elif sys.platform.startswith('linux'): - return _check_font_family_unix(value) + return _check_font_family_linux(value) elif os.name == 'nt': return _check_font_family_mac(value) + else: + raise NotImplementedError('Cannot request fonts on this system!') def get_system_font(keyword): """Return a font object from given system font keyword.""" - from .constants import SYSTEM_FONT_KEYWORDS + from .constants import SYSTEM_FONT_KEYWORDS # noqa if keyword in SYSTEM_FONT_KEYWORDS: # Get the system font diff --git a/colosseum/parser.py b/colosseum/parser.py index 558bd0d83..6b7ced777 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -130,9 +130,8 @@ def color(value): raise ValueError('Unknown color %s' % value) - ############################################################################## -# # Font handling +# Font handling ############################################################################## def _parse_font_property_part(value, font_dict): """Parse font shorthand property part for known properties.""" diff --git a/colosseum/validators.py b/colosseum/validators.py index e9b584991..52e0617b2 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -140,8 +140,7 @@ def is_font_family(value): if not font_database.validate_font_family(no_quotes_val): raise exceptions.ValidationError('Font family "{font_value}"' - ' not found on system!'.format(font_value=no_quotes_val)) - + ' not found on system!'.format(font_value=no_quotes_val)) elif val in generic_family: checked_values.append(val) else: diff --git a/tests/test_declaration.py b/tests/test_declaration.py index ec656ac89..9a22b08d4 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -450,7 +450,7 @@ def test_list_property(self): node.style.font_family = ['serif'] node.style.font_family = ["Ahem", 'serif'] - # TODO: This will coerce to a list, is this a valid behavior? + # This will coerce to a list, is this a valid behavior? node.style.font_family = 'Ahem' self.assertEqual(node.style.font_family, ['Ahem']) node.style.font_family = ' Ahem , serif ' @@ -459,7 +459,7 @@ def test_list_property(self): # Check invalid values with self.assertRaises(ValueError): node.style.font_family = ['DejaVu Sans'] # Should have additional quotes - + # Check the error message try: node.style.font_family = ['123'] @@ -635,9 +635,14 @@ def test_str(self): self.assertEqual( str(node.style), - "display: block; height: 20px; " - "margin-bottom: 50px; margin-left: 60px; " - "margin-right: 40px; margin-top: 30px; width: 10px" + "display: block; " + "font: normal normal normal medium/normal initial; " + "height: 20px; " + "margin-bottom: 50px; " + "margin-left: 60px; " + "margin-right: 40px; " + "margin-top: 30px; " + "width: 10px" ) def test_dict(self): diff --git a/tests/test_fonts.py b/tests/test_fonts.py index 2276c8d7a..e69de29bb 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -1,76 +0,0 @@ -from unittest import TestCase - -from colosseum.constants import SYSTEM_FONT_KEYWORDS -from colosseum.parser import parse_font -from colosseum.exceptions import ValidationError - - -FONT_CASES = { - r'12px/14px sans-serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': '12px', - 'line_height': '14px', - 'font_family': ['sans-serif'], - }, - r'80% sans-serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': '80%', - 'line_height': 'normal', - 'font_family': ['sans-serif'], - }, - r'bold italic large Ahem, serif': { - 'font_style': 'italic', - 'font_variant': 'normal', - 'font_weight': 'bold', - 'font_size': 'large', - 'line_height': 'normal', - 'font_family': ['Ahem', 'serif'], - }, - r'normal small-caps 120%/120% fantasy': { - 'font_style': 'normal', - 'font_variant': 'small-caps', - 'font_weight': 'normal', - 'font_size': '120%', - 'line_height': '120%', - 'font_family': ['fantasy'], - }, - r'x-large/110% Ahem,serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': 'x-large', - 'line_height': '110%', - 'font_family': ['Ahem', 'serif'], - }, -} - - -class FontTests(TestCase): - - def test_parse_font_shorthand(self): - for case in sorted(FONT_CASES): - expected_output = FONT_CASES[case] - font = parse_font(case) - self.assertEqual(font, expected_output) - - # Test extra spaces - parse_font(r' normal normal normal 12px/12px serif ') - parse_font(r' normal normal normal 12px/12px Ahem , serif ') - - # Test valid single part - for part in SYSTEM_FONT_KEYWORDS: - parse_font(part) - - def test_parse_font_shorthand_invalid(self): - # This font string has too many parts - with self.assertRaises(ValidationError): - parse_font(r'normal normal normal normal 12px/12px serif') - - # This invalid single part - for part in ['normal', 'foobar']: - with self.assertRaises(ValidationError): - parse_font(part) diff --git a/tests/test_parser.py b/tests/test_parser.py index 4523ba021..2a385ac81 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,9 +2,11 @@ from colosseum import parser from colosseum.colors import hsl, rgb -from colosseum.units import ( - ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw, -) +from colosseum.constants import SYSTEM_FONT_KEYWORDS +from colosseum.exceptions import ValidationError +from colosseum.parser import parse_font +from colosseum.units import (ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, + vmax, vmin, vw) class ParseUnitTests(TestCase): @@ -190,15 +192,80 @@ def test_named_color(self): class ParseFontTests(TestCase): + TEST_CASES = { + r'12px/14px sans-serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': '12px', + 'line_height': '14px', + 'font_family': ['sans-serif'], + }, + r'80% sans-serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': '80%', + 'line_height': 'normal', + 'font_family': ['sans-serif'], + }, + r'bold italic large Ahem, serif': { + 'font_style': 'italic', + 'font_variant': 'normal', + 'font_weight': 'bold', + 'font_size': 'large', + 'line_height': 'normal', + 'font_family': ['Ahem', 'serif'], + }, + r'normal small-caps 120%/120% fantasy': { + 'font_style': 'normal', + 'font_variant': 'small-caps', + 'font_weight': 'normal', + 'font_size': '120%', + 'line_height': '120%', + 'font_family': ['fantasy'], + }, + r'x-large/110% Ahem,serif': { + 'font_style': 'normal', + 'font_variant': 'normal', + 'font_weight': 'normal', + 'font_size': 'x-large', + 'line_height': '110%', + 'font_family': ['Ahem', 'serif'], + }, + } + + def test_parse_font_shorthand(self): + for case in sorted(self.TEST_CASES): + expected_output = self.TEST_CASES[case] + font = parse_font(case) + self.assertEqual(font, expected_output) + + # Test extra spaces + parse_font(r' normal normal normal 12px/12px serif ') + parse_font(r' normal normal normal 12px/12px Ahem , serif ') + + # Test valid single part + for part in SYSTEM_FONT_KEYWORDS: + parse_font(part) + + def test_parse_font_shorthand_invalid(self): + # This font string has too many parts + with self.assertRaises(ValidationError): + parse_font(r'normal normal normal normal 12px/12px serif') + + # This invalid single part + for part in ['normal', 'foobar']: + with self.assertRaises(ValidationError): + parse_font(part) def test_parse_font_part(self): - pass + pass # - / # - / # - / # - / - def test_parse_font(self): pass # 5 parts with line height diff --git a/tests/test_validators.py b/tests/test_validators.py index f185b250b..2d38f7a2b 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,6 +1,5 @@ from unittest import TestCase -from colosseum.constants import GENERIC_FAMILY_FONTS from colosseum.exceptions import ValidationError from colosseum.validators import is_font_family, is_integer, is_number From d755bc590ceac926d42e3d7dff2efabe55007f6b Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 8 Jan 2020 21:07:38 -0500 Subject: [PATCH 16/32] Update CI and add font copy initialization --- .github/workflows/ci.yml | 45 +++++++++++++--- setup.py | 5 +- tests/{fonts/ahem.ttf => data/fonts/Ahem.ttf} | Bin tests/data/fonts/Ahem_Ahem!.ttf | Bin 0 -> 10876 bytes .../data/fonts/Ahem_MissingItalicOblique.ttf | Bin 0 -> 10968 bytes tests/data/fonts/Ahem_MissingNormal.ttf | Bin 0 -> 10924 bytes tests/data/fonts/Ahem_SmallCaps.ttf | Bin 0 -> 10900 bytes tests/data/fonts/Ahem_WhiteSpace.ttf | Bin 0 -> 10944 bytes tests/data/fonts/Ahem_cursive.ttf | Bin 0 -> 10888 bytes tests/data/fonts/Ahem_default.ttf | Bin 0 -> 10888 bytes tests/data/fonts/Ahem_fantasy.ttf | Bin 0 -> 10888 bytes tests/data/fonts/Ahem_inherit.ttf | Bin 0 -> 10888 bytes tests/data/fonts/Ahem_initial.ttf | Bin 0 -> 10888 bytes tests/data/fonts/Ahem_monospace.ttf | Bin 0 -> 10900 bytes tests/data/fonts/Ahem_sans-serif.ttf | Bin 0 -> 10908 bytes tests/data/fonts/Ahem_serif.ttf | Bin 0 -> 10876 bytes tests/test_declaration.py | 4 +- tests/test_parser.py | 4 +- tests/utils.py | 49 +++++++++++++++++- 19 files changed, 94 insertions(+), 13 deletions(-) rename tests/{fonts/ahem.ttf => data/fonts/Ahem.ttf} (100%) create mode 100644 tests/data/fonts/Ahem_Ahem!.ttf create mode 100644 tests/data/fonts/Ahem_MissingItalicOblique.ttf create mode 100644 tests/data/fonts/Ahem_MissingNormal.ttf create mode 100644 tests/data/fonts/Ahem_SmallCaps.ttf create mode 100644 tests/data/fonts/Ahem_WhiteSpace.ttf create mode 100644 tests/data/fonts/Ahem_cursive.ttf create mode 100644 tests/data/fonts/Ahem_default.ttf create mode 100644 tests/data/fonts/Ahem_fantasy.ttf create mode 100644 tests/data/fonts/Ahem_inherit.ttf create mode 100644 tests/data/fonts/Ahem_initial.ttf create mode 100644 tests/data/fonts/Ahem_monospace.ttf create mode 100644 tests/data/fonts/Ahem_sans-serif.ttf create mode 100644 tests/data/fonts/Ahem_serif.ttf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e32531fb..93eb97e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,16 +30,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Copy Ahem font - run: | - mkdir -p ~/.local/share/fonts/ - cp tests/fonts/ahem.ttf ~/.local/share/fonts/ahem.ttf - name: Set up Python 3.7 uses: actions/setup-python@v1 with: python-version: 3.7 - name: Install dependencies run: | + sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config pip install --upgrade pip setuptools pytest-tldr pip install -e . - name: Test @@ -57,13 +54,47 @@ jobs: steps: - uses: actions/checkout@v1 - name: Copy Ahem font - run: | - mkdir -p ~/.local/share/fonts/ - cp tests/fonts/ahem.ttf ~/.local/share/fonts/ahem.ttf - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config + pip install --upgrade pip setuptools + pip install -e . + - name: Test + run: | + python setup.py test + + windows: + name: Winforms backend tests + needs: python-versions + runs-on: windows-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.5 + uses: actions/setup-python@v1 + with: + python-version: 3.5 + - name: Install dependencies + run: | + pip install --upgrade pip setuptools + pip install -e . + - name: Test + run: | + python setup.py test + + macOS: + name: macOS backend tests + needs: python-versions + runs-on: macos-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.5 + uses: actions/setup-python@v1 + with: + python-version: 3.5 - name: Install dependencies run: | pip install --upgrade pip setuptools diff --git a/setup.py b/setup.py index 87d04ed80..fe86afb5c 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,10 @@ url='https://github.com/pybee/colosseum', packages=find_packages(exclude=['tests', 'utils']), python_requires='>=3.5', - install_requires=[], + install_requires=[ + 'pygobject>=3.14.0;sys_platform=="linux"', + 'rubicon-objc;sys_platform=="darwin"', + ], license='New BSD', classifiers=[ 'Development Status :: 4 - Beta', diff --git a/tests/fonts/ahem.ttf b/tests/data/fonts/Ahem.ttf similarity index 100% rename from tests/fonts/ahem.ttf rename to tests/data/fonts/Ahem.ttf diff --git a/tests/data/fonts/Ahem_Ahem!.ttf b/tests/data/fonts/Ahem_Ahem!.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8e874110decef613b34696d4debff87bab76d1b5 GIT binary patch literal 10876 zcmeHNYjjlA75?sH-h(EH6eH4;X^a$UAZRI4ioB$eBGLpAt<_9sZZgr#Oq`jRpkTpT zq>+c>D=I}w6%gM_6$L3qML?>lqEeJ0MXRYsOf9%v7MOnL+#vDMUtRvF>)u&+?mqjR zv+q9peD|I+vp;A60+<5>L(i=kGOTIpq=kSsg4)VRJY4_I(8}`xV?JO`t_i2>@g4Yp zL3OOptErn=efDFSU4U;BP!Nq-;ppxUlB-y^f#>iT6&}A)M0r2YqF6lB5G_0|Tc4dOt|t zHH>>=-y>z096Ra&0!9Zlcr1Cz1@ihrW;^ZXMi?E2V9|gswLI9*5IK&~ZqILNspnxR z%>j73`+T{cw>LVp8{nd_L8nJpY+tqKF-$Y4g~@TqV-E089z>Zc@az23#sgy@y7X=z z1hxoh`D0jT2<53zo-s~GJd~1ie9I$1)DWXUe;bYZIF=;t`oq+Vd7VPkxNr=LaW!&U zsaByiX-o7M^sV}C<7VStV}r3tfA zc5W}9xw*Mnxf!`LfkT})Y40Kh(aeec7I8_B@c?o86v$ zoqiy@ExR#$PqsOG-R^(CS(NvY9S2O3b@GtG_|k)TSXF%Ws}uXas>JQM1=rx$_&pZk zX^Jc-S?5iQt;t#}bP;||<|Kj2P;QHARf z!74;?H&)_4tj4`qgN?B8AnwNlSc+;qj|F%H58+|dU=vy~54DKlQq&0!y9;) z-mk!WaO-|KW=7~sL1J0&j>eJe*91qY?4AZpBFzmC-+%Hg=I06#L@#3RSmYrey>T4+ z;CS@qYS0fSpa3Tlr6*wkz6*gugz!BS;bbE96k_Z97=%+X7(YM>hF~a4F$|~Sbo>y* z@gt1D85oHlV-$XZ(Kr)hFc#x59usgDCgP_k!`Vc@&oCM1@Ci55Wm1h_$4mJC8(soa(*J58;i40!X$L@amGB!sLPnA8Fd-+M#g(4BYqWQ zKAZ8D(SDOikT|&(yNQE&#KwHQO;j}Fea`y=bPy*Wppz(CjCUCSzhejfK_uOXS2?dY z5h=G2Q}5v~_z$*onWY`MZlqU5+;FB`VwTH#G}yaX#O` zF6<%N?k46|5@|NV?o;RjQ6_Qq2p%OS))KikDxM@J)~mI*59ddILX7ul#_mE!;R=pt z3GekFTWsU~-^G4S*YdOh+F)&@R;EqYqFPFuqb<^wYHPH0+GcHswpZ)Y4(ma^Kp&_N z*C*)ZdZnJwXX(v)i@s7{tGDWHdRFh$KQp{WU!%wvW{feW7#A6r8coJLzC~6T4;vee z?Zz&y!Jn9>+1nImi8;!gWX>?7X3CsnE;5&zYs_`#W^;$R*X%M6yMnF)*Fe{B*92F& ztJ0Nl&2lxnT3jn#YhA6bHdofw>H5s=b@z1_xre#OxTm-;a$o9ha?f+$>|Wu1*uBxc z-M!1*;r_&9dU|_=r^GYLGs!cur9poLs z&qVJuZtEtu?tjp~!N1MF)Bmo2e?Sl9 z1qK8L2Sx_U0@DN0Kq@dNuqd!JuqLoBusN_Jus6^ZI2;TH3xWfK!-Ero<-y8eA~-A9 z9Bc`$46Y5f2HS$!U}x~NUfy1PdlmH>#wVNm1miM85MdtRwEV*SlMC|;XY2b8E!OrQ z4jSTM`)sZse6qWDLR@3msL<#**pHQRr{&bi9ikH#2NTZo;Jgc*cY(7$IBVlKyf5Ic zgX?W+;qhwC+2ZU$?0>1gwR;dw_gCMm?atoQ*^@i_Zs)g{^E=e}4gNLj!#BJKaenhV z{{uPyfjN6HXAkD=!JIvqvj=nbVE<(g2ArrJ&B8C^ieVob{MwO{j$^dd-FgEd?eT8e z?9s-BzS_=iy<6$|Z?igH*7nKh^U&bZpYE1*c=Xn8*+9O&wOeL(`tvp}1oU0qdUp?f zUJp6Hhn?O^PiBSW=P2)O*&d56+heh1dn~qWkHwblvDmUb7F*6!^DSZ%a4`?Gv_I`lXpMjOO@(j zF^^u$J{Gh2f;sp!rE1>0j-_h$NU0tc^%>Q!hUJK|B-svW>ZCqN3(}XFs+}BVTG^Yb z?i!^%>6siw7261A`;#om@Jx$9U0+T@(|M0RVnL@ zltke&D16nB0V#mvEu0x zF*TKpHbktbsG2FJC8JR>F&s-Jtd!l8j*YLV5EbEgLn>T*N?KHun=k%6H8*|p1`e;=SyXR9yrO#YuZCjTqLWUgYD zNN&k|#VfgnM$XBrWTQxi$s3AevWsk%x5#73l1(D{B^_j{ysy|RACh_U5xVeCe2jmQp?LtG zs2P&HlJ(@AtR@5LPQ@^JPH{}0Cnu(rT$uaFirPr7(greNTF7d-oeY&FWVS3;yq8sE mO}#?C(=%j2>Y8azIdAf0y=;jGEDZYV}&9yBt~jQ;=z43)qD literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_MissingItalicOblique.ttf b/tests/data/fonts/Ahem_MissingItalicOblique.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2c76d57cdb1961d0349d996537c1a74944947f2d GIT binary patch literal 10968 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_MissingNormal.ttf b/tests/data/fonts/Ahem_MissingNormal.ttf new file mode 100644 index 0000000000000000000000000000000000000000..96cc52e70a373979f028b8b22bd98133c32691bb GIT binary patch literal 10924 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_WhiteSpace.ttf b/tests/data/fonts/Ahem_WhiteSpace.ttf new file mode 100644 index 0000000000000000000000000000000000000000..10c218a1e97dbd60d4524486929fec58c0d47adc GIT binary patch literal 10944 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_default.ttf b/tests/data/fonts/Ahem_default.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6f77099bc85e4d55c8318d3024ee8dea196aa291 GIT binary patch literal 10888 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_fantasy.ttf b/tests/data/fonts/Ahem_fantasy.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a357cb523e04fa26d27ebf3fd47166ea4d88baca GIT binary patch literal 10888 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_inherit.ttf b/tests/data/fonts/Ahem_inherit.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2357c694bd77cb03b2fbe97191d8d69fce689a3f GIT binary patch literal 10888 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_initial.ttf b/tests/data/fonts/Ahem_initial.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1fb73f05c1519bd357ca7db1619f37e7937646ec GIT binary patch literal 10888 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_monospace.ttf b/tests/data/fonts/Ahem_monospace.ttf new file mode 100644 index 0000000000000000000000000000000000000000..aeacf2e5f68bc4bcd275b4660f2753b13bc6d928 GIT binary patch literal 10900 zcmeHNZFp406@Kr>elMCtpcs)(wlPwqfuN;GDIy>uMWhKLTB_OX-ejZM-MG6kLBWEh zNFyJLUr{Mis;KydN-YYdh>D1)sZg{iONv%gjhZ6(_*h{3&fFkT?5{rlsL#Fo+`VVc z%$$4X%zN+5?s=gB2w*l048EXZ(2%Cd6BYv6Fls9!@o@b+gDWorjQM~$u_m0Z$9LcZ z2FzuBUQOMM>hqq+>;ilnfx>9a3P*Q;kbHo3>v;~1QQ`3$#gzB+ERMx9O`cZFqWu=0 zops4bSTvj(0z5ld?udt*>hYPhuk~SjkqF1FPex6ry<*xh&#h0UGYeZ!ZU*|-0eT-u z-!+Wheqyh|mmNRi00KrkHFzR<*~RkuLT1}eAUDitHw23YbgAXReul_#jCOl|OH17k zLun4c+tuf*^}M~&uH6V1h4nf;!eaZXJ&z%pK`l&}nlT?Y;&$AI=Wsp#fH_!*7qA7t!D9RxEASLH<0iCVD_+D)xCM9OUi=<+A&e^A zfC%nG6!%~y9>o240IRS879PPvco<7jjpwldkKs|QMh!M%6Xu~7F2Ac1-$ zaV1j7AdLoGg+HSaGth*qF%#F|Zrse7o`qlX74i(8#d18s_v(}QBi3Ra*5GOU7R&HD z-lg{|@E+W{Uyhj(`bv;k*1Mu{|P?a3TtE5>a|G`r*3}C_)I|LorSvQcoqezK;Po4FmB5lwuGDqYOiEI?lik zF%&<-Fr10u_%TM{Cm4ydFbZd5G{#^o&cQgGi}5&*2>2-`;(R^<7hp1ehH_M33NFM{ zT!d-37?+Eu($~;~rhqNXE5T| zFy^xuZyD`3i3Ew0x!6q{%p*4D<87j%8Siu67oeRu`2Zb6(PF&A`2Pbt@J}M?CT!=t z-b|$2N=&_nzu-f>Mtrp(%V+scB4;UH!K?TycH#~EP0iokMCx+1VlPqIhQF&>c#HG- zI(A_X(RL3pw~|P+5%!=$7l<;6tH2MoFbY?3 zJWF`5N7!O3@Bc3LYr2-F_0tAw!?p3+R4uBdwAtDsZK<|OTdQr>c4&LGPVKNB)C=|g z`cQqWUanW_34NyCtheYZ^)>n?y;aZZ9s1{n*XV5&8$*my#w269ak}qkXbggl1a<#g$t`67dZm+wyyVyO%J<2`FJ>7k|yU9JzeT#dAd$oIm zdz*WgyWRb%$Mh6@4Bu$qWZ$K}I^Wg4`M%qH z_xK+7ZS=k3d&~E}?=!#K-_sxR5B87rPxN2nkNL0i&-E|yFZVy@Qf5ZQ-e}6y^ zF6a2Zsj72Frt$!9;Lo zusPTgTp3&w+!SmLW`iBU&wF@#^zKpIV+fyY?h}kF3_*l>fYbAf@=qzsFPf!)bZD`* z|8UR{2isUdQ( zKChdc-_1^e(vw*s`8mpaTeiny%l24o*&d56+heh1dn~qWkHwbr)VORpU&X|h3zYtR z=AUKeSuj5xW1c$2Gs3c^GSWh6EY@aNE3X!F)`C5*WUgAawwOnk+9vk0n8}uwN|-4> zo8<;&N7_g*_br(Ft>-D*H>w=CyrZ-gqYsUg!jz?z1p5tA+47@h!OVGz?d08$_)?{M zSj?l>vX8}VzF-bMO{tpqu4Ad1JyNQNMSVuKt6@2!EJ?OQnmVaZ(t`A5hH58AnO62D ztGh;NPkJUtQN=cb+5RL;vOJDn#MnxDAn$ebo_55>kQ-1zeFfXaX-7u5mOO+sbydoG zBW1gt+m&D16nB0VFW zvEu1cF*%itHbktbsG1?BB%@I=E*wiGtd!l8j*Y3P5EbEgLn>T*YFbpKl8tHBcG)bA zClkqZeK=y3TQv=J;nY-)N!l+fEfZhVbyG@GoH}yXkeQ(V@wLled>^1vXR9ysPyU~{ zC;uz+WR7B;NY2T8#W%TuOp`^5S#mQOCATZa$Z|48Rw!nOY8azx^Ut|ef$(->^T!@-B3)+JZNO38UF#!D4w|h literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_sans-serif.ttf b/tests/data/fonts/Ahem_sans-serif.ttf new file mode 100644 index 0000000000000000000000000000000000000000..bc9de7820dc04d4b7f52ed3ce03661e450128d8d GIT binary patch literal 10908 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 literal 0 HcmV?d00001 diff --git a/tests/data/fonts/Ahem_serif.ttf b/tests/data/fonts/Ahem_serif.ttf new file mode 100644 index 0000000000000000000000000000000000000000..742c1646d8ee0177843e96b08d17bfb499bcaf1e GIT binary patch literal 10876 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 Date: Wed, 8 Jan 2020 21:09:44 -0500 Subject: [PATCH 17/32] Test CI --- .github/workflows/ci.yml | 15 +++--- colosseum/fonts.py | 2 +- tests/utils.py | 109 ++++++++++++++++++++++----------------- 3 files changed, 70 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93eb97e4c..a3c07fddb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: beefore --username github-actions --repository ${{ github.repository }} --pull-request ${{ github.event.number }} --commit ${{ github.event.pull_request.head.sha }} ${{ matrix.task }} . smoke: - name: Smoke test + name: Smoke test (Linux) needs: beefore runs-on: ubuntu-latest steps: @@ -41,10 +41,10 @@ jobs: pip install -e . - name: Test run: | - python setup.py test + xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test python-versions: - name: Python compatibility test + name: Python compatibility test (Linux) needs: smoke runs-on: ubuntu-latest strategy: @@ -53,7 +53,6 @@ jobs: python-version: [3.5, 3.6] steps: - uses: actions/checkout@v1 - - name: Copy Ahem font - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: @@ -65,10 +64,10 @@ jobs: pip install -e . - name: Test run: | - python setup.py test + xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test windows: - name: Winforms backend tests + name: Windows tests needs: python-versions runs-on: windows-latest steps: @@ -83,10 +82,11 @@ jobs: pip install -e . - name: Test run: | + python tests/utils.py python setup.py test macOS: - name: macOS backend tests + name: macOS tests needs: python-versions runs-on: macos-latest steps: @@ -101,4 +101,5 @@ jobs: pip install -e . - name: Test run: | + python tests/utils.py python setup.py test diff --git a/colosseum/fonts.py b/colosseum/fonts.py index b3df67f22..47784ef65 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -93,7 +93,7 @@ def check_font_family(value): elif sys.platform.startswith('linux'): return _check_font_family_linux(value) elif os.name == 'nt': - return _check_font_family_mac(value) + return _check_font_family_win(value) else: raise NotImplementedError('Cannot request fonts on this system!') diff --git a/tests/utils.py b/tests/utils.py index eb39ae06e..adce6235a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,7 +2,6 @@ import os import shutil import sys -import time from unittest import TestCase, expectedFailure from colosseum.constants import BLOCK, HTML4, MEDIUM, THICK, THIN @@ -133,31 +132,60 @@ def clean_layout(layout): def output_layout(layout, depth=1): if 'tag' in layout: return (' ' * depth - + '* {tag}{n[content][size][0]}x{n[content][size][1]}' - ' @ ({n[content][position][0]}, {n[content][position][1]})' - '\n'.format( - n=layout, - tag=('<' + layout['tag'] + '> ') if 'tag' in layout else '', - # text=(": '" + layout['text'] + "'") if 'text' in layout else '' - ) - # + ' ' * depth - # + ' padding: {n[padding_box][size][0]}x{n[padding_box][size][1]}' - # ' @ ({n[padding_box][position][0]}, {n[padding_box][position][1]})' - # '\n'.format(n=layout) - # + ' ' * depth - # + ' border: {n[border_box][size][0]}x{n[border_box][size][1]}' - # ' @ ({n[border_box][position][0]}, {n[border_box][position][1]})' - # '\n'.format(n=layout) - + ''.join( - output_layout(child, depth=depth + 1) - for child in layout.get('children', []) - ) if layout else '' - + ('\n' if layout and layout.get('children', None) and depth > 1 else '') - ) + + '* {tag}{n[content][size][0]}x{n[content][size][1]}' + ' @ ({n[content][position][0]}, {n[content][position][1]})' + '\n'.format( + n=layout, + tag=('<' + layout['tag'] + '> ') if 'tag' in layout else '', + # text=(": '" + layout['text'] + "'") if 'text' in layout else '' + ) + # + ' ' * depth + # + ' padding: {n[padding_box][size][0]}x{n[padding_box][size][1]}' + # ' @ ({n[padding_box][position][0]}, {n[padding_box][position][1]})' + # '\n'.format(n=layout) + # + ' ' * depth + # + ' border: {n[border_box][size][0]}x{n[border_box][size][1]}' + # ' @ ({n[border_box][position][0]}, {n[border_box][position][1]})' + # '\n'.format(n=layout) + + ''.join( + output_layout(child, depth=depth + 1) + for child in layout.get('children', []) + ) if layout else '' + + ('\n' if layout and layout.get('children', None) and depth > 1 else '')) else: - return (' ' * depth - + "* '{text}'\n".format(text=layout['text'].strip()) - ) + return (' ' * depth + "* '{text}'\n".format(text=layout['text'].strip())) + + +def fonts_path(system=False): + """Return the path for cross platform user fonts.""" + if os.name == 'nt': + import winreg + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings('%windir%'), 'fonts') + elif sys.platform == 'darwin': + if system: + fonts_dir = os.path.expanduser('/Library/Fonts') + else: + fonts_dir = os.path.expanduser('~/Library/Fonts') + elif sys.platform.startswith('linux'): + fonts_dir = os.path.expanduser('~/.local/share/fonts/') + else: + raise NotImplementedError('System not supported!') + + return fonts_dir + + +def copy_fonts(system=False): + """Copy needed files for running tests.""" + fonts_folder = fonts_path(system=system) + if not os.path.isdir(fonts_folder): + os.makedirs(fonts_folder) + fonts_data_path = os.path.join(HERE, 'data', 'fonts') + font_files = sorted([item for item in os.listdir(fonts_data_path) if item.endswith('.ttf')]) + for font_file in font_files: + font_file_data_path = os.path.join(fonts_data_path, font_file) + font_file_path = os.path.join(fonts_folder, font_file) + if not os.path.isfile(font_file_path): + shutil.copyfile(font_file_data_path, font_file_path) class ColosseumTestCase(TestCase): @@ -167,30 +195,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.copy_fonts() - def fonts_path(self): - """Return the path for cross platform user fonts.""" - if os.name == 'nt': - import winreg - fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings('%windir%'), 'fonts') - elif sys.platform == 'darwin': - fonts_dir = os.path.expanduser('~/Library/Fonts') - elif sys.platform.startswith('linux'): - fonts_dir = os.path.expanduser('~/.local/share/fonts/') - else: - raise NotImplementedError('System not supported!') - - return fonts_dir - def copy_fonts(self): - """Copy needed files for running tests.""" - fonts_path = self.fonts_path() - fonts_data_path = os.path.join(HERE, 'data', 'fonts') - font_files = sorted([item for item in os.listdir(fonts_data_path) if item.endswith('.ttf')]) - for font_file in font_files: - font_file_data_path = os.path.join(fonts_data_path, font_file) - font_file_path = os.path.join(fonts_path, font_file) - if not os.path.isfile(font_file_path): - shutil.copyfile(font_file_data_path, font_file_path) + copy_fonts() try: FontDatabase.validate_font_family('Ahem') @@ -442,3 +448,10 @@ def test_method(self): tests[test_name] = test_method return tests + + +if __name__ == '__main__': + print('Copying test fonts...') + print(fonts_path(system=True)) + copy_fonts(system=True) + print(list(sorted(os.listdir(fonts_path())))) From dfe6e61babf62ab59940c8e93066c2ef65f7bd81 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 8 Jan 2020 22:47:01 -0500 Subject: [PATCH 18/32] Update font registry on windows --- .github/workflows/ci.yml | 14 +++++++-- colosseum/fonts.py | 56 ++++++++++++++++++++++++--------- tests/utils.py | 68 ++++++++++++++++++++++++++-------------- 3 files changed, 98 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3c07fddb..b9cfa6109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config pip install --upgrade pip setuptools pytest-tldr pip install -e . + - name: Install fonts + run: | + xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test @@ -62,6 +65,9 @@ jobs: sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config pip install --upgrade pip setuptools pip install -e . + - name: Install fonts + run: | + xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test @@ -80,9 +86,11 @@ jobs: run: | pip install --upgrade pip setuptools pip install -e . - - name: Test + - name: Install fonts run: | python tests/utils.py + - name: Test + run: | python setup.py test macOS: @@ -99,7 +107,9 @@ jobs: run: | pip install --upgrade pip setuptools pip install -e . - - name: Test + - name: Install fonts run: | python tests/utils.py + - name: Test + run: | python setup.py test diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 47784ef65..a7752e560 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -28,6 +28,31 @@ def validate_font_family(cls, value): raise ValidationError('Font family "{value}" not found on system!'.format(value=value)) + @staticmethod + def fonts_path(system=False): + """Return the path for cross platform user fonts.""" + if os.name == 'nt': + import winreg + if system: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%windir%'), 'Fonts') + else: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%LocalAppData%'), + 'Microsoft', 'Windows', 'Fonts') + elif sys.platform == 'darwin': + if system: + fonts_dir = os.path.expanduser('/Library/Fonts') + else: + fonts_dir = os.path.expanduser('~/Library/Fonts') + elif sys.platform.startswith('linux'): + if system: + fonts_dir = os.path.expanduser('/usr/local/share/fonts') + else: + fonts_dir = os.path.expanduser('~/.local/share/fonts/') + else: + raise NotImplementedError('System not supported!') + + return fonts_dir + def _check_font_family_mac(value): """List available font family names on mac.""" @@ -58,9 +83,11 @@ class Window(Gtk.Window): def check_system_font(self, value): """Check if font family exists on system.""" context = self.create_pango_context() - for fam in context.list_families(): - if fam.get_name() == value: + for font_family in context.list_families(): + font_name = font_family.get_name() + if font_name == value: return True + return False global _GTK_WINDOW # noqa @@ -72,16 +99,17 @@ def check_system_font(self, value): def _check_font_family_win(value): """List available font family names on windows.""" - import winreg - key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, - r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", - 0, - winreg.KEY_READ) - for idx in range(0, winreg.QueryInfoKey(key)[1]): - font_name = winreg.EnumValue(key, idx)[0] - font_name = font_name.replace(' (TrueType)', '') - if font_name == value: - return True + import winreg # noqa + for base in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + key = winreg.OpenKey(base, + r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", + 0, + winreg.KEY_READ) + for idx in range(0, winreg.QueryInfoKey(key)[1]): + font_name = winreg.EnumValue(key, idx)[0] + font_name = font_name.replace(' (TrueType)', '') + if font_name == value: + return True return False @@ -95,7 +123,7 @@ def check_font_family(value): elif os.name == 'nt': return _check_font_family_win(value) else: - raise NotImplementedError('Cannot request fonts on this system!') + raise NotImplementedError('Cannot check font existence on this system!') def get_system_font(keyword): @@ -103,7 +131,7 @@ def get_system_font(keyword): from .constants import SYSTEM_FONT_KEYWORDS # noqa if keyword in SYSTEM_FONT_KEYWORDS: - # Get the system font + # TODO: Get the system font that corresponds return 'Ahem' return None diff --git a/tests/utils.py b/tests/utils.py index adce6235a..a8028ff59 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -156,44 +156,60 @@ def output_layout(layout, depth=1): return (' ' * depth + "* '{text}'\n".format(text=layout['text'].strip())) -def fonts_path(system=False): - """Return the path for cross platform user fonts.""" - if os.name == 'nt': - import winreg - fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings('%windir%'), 'fonts') - elif sys.platform == 'darwin': - if system: - fonts_dir = os.path.expanduser('/Library/Fonts') - else: - fonts_dir = os.path.expanduser('~/Library/Fonts') - elif sys.platform.startswith('linux'): - fonts_dir = os.path.expanduser('~/.local/share/fonts/') - else: - raise NotImplementedError('System not supported!') - - return fonts_dir - - def copy_fonts(system=False): """Copy needed files for running tests.""" - fonts_folder = fonts_path(system=system) + fonts_folder = FontDatabase.fonts_path(system=system) + if not os.path.isdir(fonts_folder): os.makedirs(fonts_folder) + fonts_data_path = os.path.join(HERE, 'data', 'fonts') font_files = sorted([item for item in os.listdir(fonts_data_path) if item.endswith('.ttf')]) for font_file in font_files: font_file_data_path = os.path.join(fonts_data_path, font_file) font_file_path = os.path.join(fonts_folder, font_file) + if not os.path.isfile(font_file_path): shutil.copyfile(font_file_data_path, font_file_path) + # Register font + + if os.name == 'nt': + import winreg # noqa + base_key = winreg.HKEY_LOCAL_MACHINE if system else winreg.HKEY_CURRENT_USER + key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" + + if '_' in font_file: + font_name = font_file.split('_')[-1].split('.')[0] + else: + font_name = font_file.split('.')[0] + + # This font has a space in its system name + if font_name == 'WhiteSpace': + font_name = 'White Space' + + font_name = font_name + ' (TrueType)' + + with winreg.OpenKey(base_key, key_path, 0, winreg.KEY_ALL_ACCESS) as reg_key: + value = None + try: + # Query if it exists + value = winreg.QueryValueEx(reg_key, font_name) + except FileNotFoundError: + pass + + # If it does not exists, add value + if value != font_file_path: + winreg.SetValueEx(reg_key, font_name, 0, winreg.REG_SZ, font_file_path) class ColosseumTestCase(TestCase): """Install test fonts before running tests that use them.""" + _FONTS_ACTIVE = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.copy_fonts() + if self._FONTS_ACTIVE is False: + self.copy_fonts() def copy_fonts(self): copy_fonts() @@ -204,6 +220,8 @@ def copy_fonts(self): raise Exception('\n\nTesting fonts (Ahem & Ahem Extra) are not active.\n' '\nPlease run the test suite one more time.\n') + ColosseumTestCase._FONTS_ACTIVE = True + class LayoutTestCase(ColosseumTestCase): def setUp(self): @@ -451,7 +469,9 @@ def test_method(self): if __name__ == '__main__': - print('Copying test fonts...') - print(fonts_path(system=True)) - copy_fonts(system=True) - print(list(sorted(os.listdir(fonts_path())))) + # On CI we use system font locations except for linux containers + system = bool(os.environ.get('GITHUB_WORKSPACE', None)) + if sys.platform.startswith('linux'): + system = False + print('Copying test fonts to "{path}"...'.format(path=FontDatabase.fonts_path(system=system))) + copy_fonts(system=system) From 8d063b92ec8f7b710e04dd0c81988a9567901cf7 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:12:33 -0500 Subject: [PATCH 19/32] Add more tests --- colosseum/constants.py | 27 +- colosseum/declaration.py | 39 ++- colosseum/fonts.py | 8 +- colosseum/parser.py | 31 +- colosseum/validators.py | 8 +- tests/test_declaration.py | 93 +++++- tests/test_fonts.py | 24 ++ tests/test_parser.py | 632 ++++++++++++++++++++++++++++++-------- tests/test_validators.py | 5 + 9 files changed, 716 insertions(+), 151 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index ba7a8f6e9..a656d647c 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -365,21 +365,30 @@ def __str__(self): BOLD = 'bold' BOLDER = 'bolder' LIGHTER = 'lighter' +WEIGHT_100 = '100' +WEIGHT_200 = '200' +WEIGHT_300 = '300' +WEIGHT_400 = '400' +WEIGHT_500 = '500' +WEIGHT_600 = '600' +WEIGHT_700 = '700' +WEIGHT_800 = '800' +WEIGHT_900 = '900' FONT_WEIGHT_CHOICES = Choices( NORMAL, BOLD, BOLDER, LIGHTER, - 100, - 200, - 300, - 400, - 500, - 600, - 700, - 800, - 900, + WEIGHT_100, + WEIGHT_200, + WEIGHT_300, + WEIGHT_400, + WEIGHT_500, + WEIGHT_600, + WEIGHT_700, + WEIGHT_800, + WEIGHT_900, explicit_defaulting_constants=[INHERIT], ) diff --git a/colosseum/declaration.py b/colosseum/declaration.py index c25d05f4a..7f8895863 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -17,7 +17,7 @@ default, ) from .exceptions import ValidationError -from .parser import construct_font, parse_font +from .parser import construct_font, construct_font_family, parse_font _CSS_PROPERTIES = set() @@ -41,8 +41,9 @@ def setter(self, value): for property_name, property_value in font.items(): setattr(self, property_name, property_value) - self.dirty = True + setattr(self, '_%s' % name, value) + self.dirty = True def deleter(self): try: @@ -64,20 +65,36 @@ def deleter(self): return property(getter, setter, deleter) -def validated_list_property(name, choices, initial, separator=','): +def validated_list_property(name, choices, initial, separator=',', add_quotes=False): """Define a property holding a list values.""" if not isinstance(initial, list): raise ValueError('Initial value must be a list!') + def _add_quotes(values): + """Add quotes to items that contain spaces.""" + quoted_values = [] + for value in values: + if ' ' in value: + value = '"{value}"'.format(value=value) + quoted_values.append(value) + + return quoted_values + def getter(self): return getattr(self, '_%s' % name, initial).copy() def setter(self, value): if not isinstance(value, str): + if add_quotes: + value = _add_quotes(value) value = separator.join(value) try: - # This should be a list of values + # The validator must validate all the values at once + # because the order of parameters might be important. values = choices.validate(value) + + # The validator must return a list + assert isinstance(values, list) except ValueError: raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( value, name, choices @@ -360,7 +377,8 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=[INITIAL]) + font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=[INITIAL], + add_quotes=True) # 15.4 Font Styling font_style = validated_property('font_style', choices=FONT_STYLE_CHOICES, initial=NORMAL) @@ -589,7 +607,16 @@ def __str__(self): non_default = [] for name in _CSS_PROPERTIES: if name == 'font': - non_default.append((name, construct_font(getattr(self, name)))) + try: + if getattr(self, '_%s' % name, None): + non_default.append((name, construct_font(getattr(self, name)))) + except AttributeError: + pass + elif name == 'font_family': + try: + non_default.append((name.replace('_', '-'), construct_font_family(getattr(self, '_%s' % name)))) + except AttributeError: + pass else: try: non_default.append(( diff --git a/colosseum/fonts.py b/colosseum/fonts.py index a7752e560..40544b371 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -15,6 +15,10 @@ class FontDatabase: """ _FONTS_CACHE = {} + @classmethod + def clear_cache(cls): + cls._FONTS_CACHE = {} + @classmethod def validate_font_family(cls, value): """Validate a font family with the system found fonts.""" @@ -128,10 +132,10 @@ def check_font_family(value): def get_system_font(keyword): """Return a font object from given system font keyword.""" - from .constants import SYSTEM_FONT_KEYWORDS # noqa + from .constants import INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS # noqa if keyword in SYSTEM_FONT_KEYWORDS: # TODO: Get the system font that corresponds - return 'Ahem' + return INITIAL_FONT_VALUES.copy() return None diff --git a/colosseum/parser.py b/colosseum/parser.py index 6b7ced777..eaeb24b8b 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -1,3 +1,5 @@ +import ast + from .colors import NAMED_COLOR, hsl, rgb from .exceptions import ValidationError from .fonts import get_system_font @@ -149,13 +151,15 @@ def _parse_font_property_part(value, font_dict): except (ValidationError, ValueError): pass - # Maybe it is a font size + if '/' in value: + # Maybe it is a font size with line height font_dict['font_size'], font_dict['line_height'] = value.split('/') FONT_SIZE_CHOICES.validate(font_dict['font_size']) LINE_HEIGHT_CHOICES.validate(font_dict['line_height']) return font_dict else: + # Or just a font size try: FONT_SIZE_CHOICES.validate(value) font_dict['font_size'] = value @@ -185,7 +189,7 @@ def parse_font(string): font_dict = INITIAL_FONT_VALUES.copy() # Remove extra spaces - string = ' '.join(string.strip().split()) + string = ' '.join(str(string).strip().split()) parts = string.split(' ', 1) if len(parts) == 1: @@ -199,7 +203,7 @@ def parse_font(string): if value not in SYSTEM_FONT_KEYWORDS: error_msg = ('Font property value "{value}" ' 'not a system font keyword!'.format(value=value)) - raise ValidationError(error_msg) + raise ValueError(error_msg) font_dict = get_system_font(value) else: # If font is specified as a shorthand for several font-related properties, then: @@ -225,7 +229,7 @@ def parse_font(string): else: # Font family can have a maximum of 4 parts before the font_family part. # / - raise ValidationError('Font property shorthand contains too many parts!') + raise ValueError('Font property shorthand contains too many parts!') value = ' '.join(parts) font_dict['font_family'] = FONT_FAMILY_CHOICES.validate(value) @@ -235,8 +239,21 @@ def parse_font(string): def construct_font(font_dict): """Construct font property string from a dictionary of font properties.""" - if isinstance(font_dict['font_family'], list): - font_dict['font_family'] = ', '.join(font_dict['font_family']) + font_dict_copy = font_dict.copy() + font_dict_copy['font_family'] = construct_font_family(font_dict_copy['font_family']) return ('{font_style} {font_variant} {font_weight} ' - '{font_size}/{line_height} {font_family}').format(**font_dict) + '{font_size}/{line_height} {font_family}').format(**font_dict_copy) + + +def construct_font_family(font_family): + """Construct a font family property from a list of font families.""" + assert isinstance(font_family, list) + checked_font_family = [] + for family in font_family: + if ' ' in family: + family = '"{value}"'.format(value=family) + + checked_font_family.append(family) + + return ', '.join(checked_font_family) diff --git a/colosseum/validators.py b/colosseum/validators.py index 52e0617b2..41dd72428 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -120,8 +120,11 @@ def is_font_family(value): """ from .constants import GENERIC_FAMILY_FONTS as generic_family font_database = fonts.FontDatabase + + # Remove extra outer spaces font_value = ' '.join(value.strip().split()) values = [v.strip() for v in font_value.split(',')] + checked_values = [] for val in values: # Remove extra inner spaces @@ -129,18 +132,17 @@ def is_font_family(value): val = val.replace(' "', '"') val = val.replace("' ", "'") val = val.replace(" '", "'") - if (val.startswith('"') and val.endswith('"') or val.startswith("'") and val.endswith("'")): try: no_quotes_val = ast.literal_eval(val) - checked_values.append(val) except ValueError: raise exceptions.ValidationError if not font_database.validate_font_family(no_quotes_val): raise exceptions.ValidationError('Font family "{font_value}"' ' not found on system!'.format(font_value=no_quotes_val)) + checked_values.append(no_quotes_val) elif val in generic_family: checked_values.append(val) else: @@ -156,7 +158,7 @@ def is_font_family(value): invalid = set(values) - set(checked_values) error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) raise exceptions.ValidationError(error_msg) - + return checked_values diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 583121375..d331ca35f 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -636,7 +636,6 @@ def test_str(self): self.assertEqual( str(node.style), "display: block; " - "font: normal normal normal medium/normal initial; " "height: 20px; " "margin-bottom: 50px; " "margin-left: 60px; " @@ -745,19 +744,109 @@ def test_font_shorthand_property(self): self.assertEqual(node.style.font_family, ['serif']) # Check individual properties do not update the set shorthand + node.style.font = '9px "White Space", serif' + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': ['White Space', 'serif'], + } + font = node.style.font + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) + + # Check string + self.assertEqual(str(node.style), ( + 'font: italic small-caps bold 10px/1.5 "White Space", serif; ' + 'font-family: "White Space", serif; ' + 'font-size: 10px; ' + 'font-style: italic; ' + 'font-variant: small-caps; ' + 'font-weight: bold; ' + 'line-height: 1.5' + )) + node.style.font = '9px "White Space", serif' + self.assertEqual(str(node.style), ( + 'font: normal normal normal 9px/normal "White Space", serif; ' + 'font-family: "White Space", serif; ' + 'font-size: 9px; ' + 'font-style: normal; ' + 'font-variant: normal; ' + 'font-weight: normal; ' + 'line-height: normal' + )) + + # Check invalid values + with self.assertRaises(ValueError): + node.style.font = 'ThisIsDefinitelyNotAFontName' + + def test_font_family_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check initial value + self.assertEqual(node.style.font, INITIAL_FONT_VALUES) + + # Check Initial values + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.font_size, 'medium') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(node.style.font_family, ['initial']) + + # Check individual properties update the unset shorthand + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + node.style.font_family = ['Ahem', 'serif'] + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': ['Ahem', 'serif'], + } + font = node.style.font + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) + + # Check setting the shorthand resets values node.style.font = '9px serif' + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(str(node.style.font_size), '9px') + self.assertEqual(node.style.font_family, ['serif']) + + # Check individual properties do not update the set shorthand + node.style.font = '9px "White Space", Ahem, serif' node.style.font_style = 'italic' node.style.font_weight = 'bold' node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' + node.style.font_family = ['White Space', 'serif'] expected_font = { 'font_style': 'italic', 'font_weight': 'bold', 'font_variant': 'small-caps', 'font_size': '10px', 'line_height': '1.5', - 'font_family': ['serif'], + 'font_family': ['White Space', 'serif'], } font = node.style.font font['font_size'] = str(font['font_size']) diff --git a/tests/test_fonts.py b/tests/test_fonts.py index e69de29bb..fa745e706 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -0,0 +1,24 @@ +from colosseum.fonts import FontDatabase, get_system_font +from colosseum.constants import INITIAL_FONT_VALUES + +from .utils import ColosseumTestCase + + +class ParseFontTests(ColosseumTestCase): + + def test_font_database(self): + # Check empty cache + FontDatabase.clear_cache() + self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noqa + + # Check populated cache + FontDatabase.validate_font_family('Ahem') + FontDatabase.validate_font_family('White Space') + self.assertEqual(FontDatabase._FONTS_CACHE, {'Ahem': None, 'White Space': None}) # noqa + + # Check returns a string + self.assertTrue(bool(FontDatabase.fonts_path(system=True))) + self.assertTrue(bool(FontDatabase.fonts_path(system=False))) + + def test_get_system_font(self): + self.assertEqual(get_system_font('status-bar'), INITIAL_FONT_VALUES) diff --git a/tests/test_parser.py b/tests/test_parser.py index fb6fb950b..98265c635 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,11 +1,14 @@ from unittest import TestCase + from colosseum import parser from colosseum.colors import hsl, rgb from colosseum.constants import SYSTEM_FONT_KEYWORDS from colosseum.exceptions import ValidationError -from colosseum.parser import parse_font -from colosseum.units import (ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, - vmax, vmin, vw) +from colosseum.parser import construct_font, construct_font_family, parse_font +from colosseum.units import ( + ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw, +) + from .utils import ColosseumTestCase @@ -192,128 +195,513 @@ def test_named_color(self): class ParseFontTests(ColosseumTestCase): - TEST_CASES = { - r'12px/14px sans-serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': '12px', - 'line_height': '14px', - 'font_family': ['sans-serif'], - }, - r'80% sans-serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': '80%', - 'line_height': 'normal', - 'font_family': ['sans-serif'], - }, - r'bold italic large Ahem, serif': { - 'font_style': 'italic', - 'font_variant': 'normal', - 'font_weight': 'bold', - 'font_size': 'large', - 'line_height': 'normal', - 'font_family': ['Ahem', 'serif'], - }, - r'normal small-caps 120%/120% fantasy': { - 'font_style': 'normal', - 'font_variant': 'small-caps', - 'font_weight': 'normal', - 'font_size': '120%', - 'line_height': '120%', - 'font_family': ['fantasy'], - }, - r'x-large/110% Ahem,serif': { - 'font_style': 'normal', - 'font_variant': 'normal', - 'font_weight': 'normal', - 'font_size': 'x-large', - 'line_height': '110%', - 'font_family': ['Ahem', 'serif'], - }, + CASE_5 = { + # / + 'oblique small-caps bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal small-caps bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'oblique normal bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'oblique small-caps normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'normal small-caps normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'oblique normal normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'normal normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'bold oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'bold normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'bold oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'bold normal normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'small-caps bold oblique 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal bold oblique 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'small-caps normal oblique 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'small-caps bold normal 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal bold normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'small-caps normal normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'normal normal oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'small-caps oblique bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal oblique bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'small-caps normal bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'small-caps oblique normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + } + CASE_4 = { + # / + 'oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'oblique bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + + # / + 'bold oblique 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'bold normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'bold small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), } + CASE_3 = { + # / + 'oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - def test_parse_font_shorthand(self): - for case in sorted(self.TEST_CASES): - expected_output = self.TEST_CASES[case] - font = parse_font(case) - self.assertEqual(font, expected_output) + # / + 'small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + + # / + 'bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + + # + 'oblique 1.2em Ahem': ('oblique', 'normal', 'normal', '1.2em', 'normal', ['Ahem']), + + # + 'small-caps 1.2em Ahem': ('normal', 'small-caps', 'normal', '1.2em', 'normal', ['Ahem']), + + # + 'bold 1.2em Ahem': ('normal', 'normal', 'bold', '1.2em', 'normal', ['Ahem']), + } + CASE_2 = { + # / + '1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), + '1.2em/3 Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', '3', + ['Ahem', 'White Space']), + '1.2em/3 Ahem, "White Space", serif': ('normal', 'normal', 'normal', '1.2em', '3', + ['Ahem', 'White Space', 'serif']), + + # + '1.2em Ahem': ('normal', 'normal', 'normal', '1.2em', 'normal', ['Ahem']), + '1.2em Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', 'normal', + ['Ahem', 'White Space']), + '1.2em Ahem, "White Space", serif': ('normal', 'normal', 'normal', '1.2em', 'normal', + ['Ahem', 'White Space', 'serif']), + } + CASE_1 = { + # | inherit + 'caption': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'icon': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'menu': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'message-box': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'small-caption': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'status-bar': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + 'inherit': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), + } + CASE_EXTRAS = { + # Spaces in family + 'normal normal normal 1.2em/3 Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', '3', + ['Ahem', 'White Space']), + "normal normal normal 1.2em/3 Ahem, 'White Space'": ('normal', 'normal', 'normal', '1.2em', '3', + ['Ahem', 'White Space']), + # Extra spaces + " normal normal normal 1.2em/3 Ahem, ' White Space ' ": ('normal', 'normal', 'normal', '1.2em', '3', + ['Ahem', 'White Space']), + } + CASE_5_INVALID = set([ + # / + ' small-caps bold 1.2em/3 Ahem', + ' small-caps bold 1.2em/3 Ahem', + ' normal bold 1.2em/3 Ahem', + ' small-caps normal 1.2em/3 Ahem', + ' small-caps normal 1.2em/3 Ahem', + ' normal normal 1.2em/3 Ahem', + ' normal bold 1.2em/3 Ahem', + + 'oblique bold 1.2em/3 Ahem', + 'normal bold 1.2em/3 Ahem', + 'oblique 1.2em/3 Ahem', + 'oblique normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + 'oblique normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + + 'oblique small-caps 1.2em/3 Ahem', + 'normal small-caps 1.2em/3 Ahem', + 'oblique normal 1.2em/3 Ahem', + 'oblique small-caps 1.2em/3 Ahem', + 'normal small-caps 1.2em/3 Ahem', + 'oblique normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + + 'oblique small-caps bold /3 Ahem', + 'normal small-caps bold /3 Ahem', + 'oblique normal bold /3 Ahem', + 'oblique small-caps normal /3 Ahem', + 'normal small-caps normal /3 Ahem', + 'oblique normal normal /3 Ahem', + 'normal normal bold /3 Ahem', + + 'oblique small-caps bold 1.2em/3 ', + 'normal small-caps bold 1.2em/3 ', + 'oblique normal bold 1.2em/3 ', + 'oblique small-caps normal 1.2em/3 ', + 'normal small-caps normal 1.2em/3 ', + 'oblique normal normal 1.2em/3 ', + + # / + ' oblique small-caps 1.2em/3 Ahem', + ' oblique small-caps 1.2em/3 Ahem', + ' normal small-caps 1.2em/3 Ahem', + ' oblique normal 1.2em/3 Ahem', + ' oblique normal 1.2em/3 Ahem', + ' normal normal 1.2em/3 Ahem', + ' normal small-caps 1.2em/3 Ahem', + + 'bold small-caps 1.2em/3 Ahem', + 'normal small-caps 1.2em/3 Ahem', + 'bold small-caps 1.2em/3 Ahem', + 'bold normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + 'bold normal 1.2em/3 Ahem', + 'normal small-caps 1.2em/3 Ahem', + + 'bold oblique 1.2em/3 Ahem', + 'normal oblique 1.2em/3 Ahem', + 'bold normal 1.2em/3 Ahem', + 'bold oblique 1.2em/3 Ahem', + 'normal oblique 1.2em/3 Ahem', + 'bold normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + + 'bold oblique small-caps /3 Ahem', + 'normal oblique small-caps /3 Ahem', + 'bold normal small-caps /3 Ahem', + 'bold oblique normal /3 Ahem', + 'normal oblique normal /3 Ahem', + 'bold normal normal /3 Ahem', + 'normal normal small-caps /3 Ahem', + + 'bold oblique small-caps 1.2em/ Ahem', + 'normal oblique small-caps 1.2em/ Ahem', + 'bold normal small-caps 1.2em/ Ahem', + 'bold oblique normal 1.2em/ Ahem', + 'normal oblique normal 1.2em/ Ahem', + 'bold normal normal 1.2em/ Ahem', + 'normal normal small-caps 1.2em/ Ahem', + + 'bold oblique small-caps 1.2em/3 ', + 'normal oblique small-caps 1.2em/3 ', + 'bold normal small-caps 1.2em/3 ', + 'bold oblique normal 1.2em/3 ', + 'normal oblique normal 1.2em/3 ', + 'bold normal normal 1.2em/3 ', + 'normal normal small-caps 1.2em/3 ', + + # / + ' bold oblique 1.2em/3 Ahem', + ' bold oblique 1.2em/3 Ahem', + ' normal oblique 1.2em/3 Ahem', + ' bold normal 1.2em/3 Ahem', + ' bold normal 1.2em/3 Ahem', + ' normal normal 1.2em/3 Ahem', + ' normal oblique 1.2em/3 Ahem', + + 'small-caps oblique 1.2em/3 Ahem', + 'normal oblique 1.2em/3 Ahem', + 'small-caps oblique 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + 'normal oblique 1.2em/3 Ahem', + + 'small-caps bold 1.2em/3 Ahem', + 'normal bold 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + 'small-caps bold 1.2em/3 Ahem', + 'normal bold 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + 'normal normal 1.2em/3 Ahem', + + 'small-caps bold oblique em/3 Ahem', + 'normal bold oblique /3 Ahem', + 'small-caps normal oblique /3 Ahem', + 'small-caps bold normal /3 Ahem', + 'normal bold normal /3 Ahem', + 'small-caps normal normal /3 Ahem', + 'normal normal oblique /3 Ahem', + + 'small-caps bold oblique /3 Ahem', + 'normal bold oblique /3 Ahem', + 'small-caps normal oblique /3 Ahem', + 'small-caps bold normal /3 Ahem', + 'normal bold normal /3 Ahem', + 'small-caps normal normal /3 Ahem', + 'normal normal oblique /3 Ahem', + + 'small-caps bold oblique 1.2em/ Ahem', + 'normal bold oblique 1.2em/ Ahem', + 'small-caps normal oblique 1.2em/ Ahem', + 'small-caps bold normal 1.2em/ Ahem', + 'normal bold normal 1.2em/ Ahem', + 'small-caps normal normal 1.2em/ Ahem', + 'normal normal oblique 1.2em/ Ahem', + + 'small-caps bold oblique 1.2em/3 ', + 'normal bold oblique 1.2em/3 ', + 'small-caps normal oblique 1.2em/3 ', + 'small-caps bold normal 1.2em/3 ', + 'normal bold normal 1.2em/3 ', + 'small-caps normal normal 1.2em/3 ', + 'normal normal oblique 1.2em/3 ', + + # / + ' oblique bold 1.2em/3 Ahem', + ' oblique bold 1.2em/3 Ahem', + ' normal bold 1.2em/3 Ahem', + ' oblique normal 1.2em/3 Ahem', + + 'small-caps bold 1.2em/3 Ahem', + 'normal bold 1.2em/3 Ahem', + 'small-caps bold 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + + 'small-caps oblique 1.2em/3 Ahem', + 'normal oblique 1.2em/3 Ahem', + 'small-caps normal 1.2em/3 Ahem', + 'small-caps oblique 1.2em/3 Ahem', + + 'small-caps oblique bold /3 Ahem', + 'normal oblique bold /3 Ahem', + 'small-caps normal bold /3 Ahem', + 'small-caps oblique normal /3 Ahem', + + 'small-caps oblique bold /3 Ahem', + 'normal oblique bold /3 Ahem', + 'small-caps normal bold /3 Ahem', + 'small-caps oblique normal /3 Ahem', + + 'small-caps oblique bold 1.2em/ Ahem', + 'normal oblique bold 1.2em/ Ahem', + 'small-caps normal bold 1.2em/ Ahem', + 'small-caps oblique normal 1.2em/ Ahem', + + 'small-caps oblique bold 1.2em/3 ', + 'normal oblique bold 1.2em/3 ', + 'small-caps normal bold 1.2em/3 ', + 'small-caps oblique normal 1.2em/3 ', + ]) + CASE_4_INVALID = set([ + # / + ' small-caps 1.2em/3 Ahem', + ' normal 1.2em/3 Ahem', + ' small-caps 1.2em/3 Ahem', + ' normal 1.2em/3 Ahem', + + 'oblique 1.2em/3 Ahem', + 'oblique 1.2em/3 Ahem', + 'normal 1.2em/3 Ahem', + 'normal 1.2em/3 Ahem', + + 'oblique small-caps /3 Ahem', + 'oblique normal /3 Ahem', + 'normal small-caps /3 Ahem', + 'normal normal /3 Ahem', + + 'oblique small-caps 1.2em/ Ahem', + 'oblique normal 1.2em/ Ahem', + 'normal small-caps 1.2em/ Ahem', + 'normal normal 1.2em/ Ahem', + + 'oblique small-caps 1.2em/3 ', + 'oblique normal 1.2em/3 ', + 'normal small-caps 1.2em/3 ', + 'normal normal 1.2em/3 ', + + # / + ' bold 1.2em/3 Ahem', + ' bold 1.2em/3 Ahem', + + 'oblique 1.2em/3 Ahem', + 'normal 1.2em/3 Ahem', + + 'oblique bold /3 Ahem', + 'normal bold /3 Ahem', + + 'oblique bold 1.2em/ Ahem', + 'normal bold 1.2em/ Ahem', + + 'oblique bold 1.2em/3 ', + 'normal bold 1.2em/3 ', + + # / + ' oblique 1.2em/3 Ahem', + ' normal 1.2em/3 Ahem', + ' oblique 1.2em/3 Ahem', + + 'bold 1.2em/3 Ahem', + 'bold 1.2em/3 Ahem', + 'normal 1.2em/3 Ahem', + + 'bold oblique /3 Ahem', + 'bold normal /3 Ahem', + 'normal oblique /3 Ahem', + + 'bold oblique 1.2em/ Ahem', + 'bold normal 1.2em/ Ahem', + 'normal oblique 1.2em/ Ahem', + + 'bold oblique 1.2em/3 ', + 'bold normal 1.2em/3 ', + 'normal oblique 1.2em/3 ', + + # / + ' small-caps 1.2em/3 Ahem', + + 'bold 1.2em/3 Ahem', + + 'bold small-caps /3 Ahem', + + 'bold small-caps 1.2em/ Ahem', + + 'bold small-caps 1.2em/3 ', + ]) + CASE_3_INVALID = set([ + # / + ' 1.2em/3 Ahem', + + 'oblique /3 Ahem', + + 'oblique 1.2em/ Ahem', + + 'oblique 1.2em/3 ', + + # / + ' 1.2em/3 Ahem', + + 'small-caps /3 Ahem', + + 'small-caps 1.2em/ Ahem', + + 'small-caps 1.2em/3 ', + + # / + ' 1.2em/3 Ahem', + + 'bold /3 Ahem', - # Test extra spaces - parse_font(r' normal normal normal 12px/12px serif ') - parse_font(r' normal normal normal 12px/12px Ahem , serif ') + 'bold 1.2em/ Ahem', - # Test valid single part - for part in SYSTEM_FONT_KEYWORDS: - parse_font(part) + 'bold 1.2em/3 ', + + # + ' 1.2em Ahem', + + 'oblique Ahem', + + 'oblique 1.2em ', + + # + ' 1.2em Ahem', + + 'small-caps Ahem', + + 'small-caps 1.2em ', + + # + ' 1.2em Ahem', + + 'bold Ahem', + + 'bold 1.2em ', + ]) + CASE_2_INVALID = set([ + # / + '/3 Ahem', + '1.2em/ Ahem, "White Space"', + '1.2em/3 , "White Space", serif', + '1.2em/3 Ahem, "", serif', + '1.2em/3 Ahem, , serif', + '1.2em/3 Ahem, "White Space", ', + + # + ' Ahem', + '1.2em , "White Space"', + '1.2em Ahem, "", serif', + '1.2em Ahem, , serif', + '1.2em Ahem, "White Space", ', + ]) + CASE_1_INVALID = set([ + # | inherit + 'Ahem' + '"White Space"' + '', + '20', + 20, + ]) + CASE_EXTRAS_INVALID = set([ + # Space between font-size and line-height + 'small-caps oblique normal 1.2em /3 Ahem' + 'small-caps oblique normal 1.2em/ 3 Ahem' + 'small-caps oblique normal 1.2em / 3 Ahem' + + # Too many parts + 'normal normal normal normal 12px/12px serif' + 'normal normal normal normal normal 12px/12px serif' + + # No quotes with spaces + 'small-caps oblique normal 1.2em/3 Ahem, White Space' + + # No commas + 'small-caps oblique normal 1.2em/3 Ahem "White Space"' + ]) + + # Font construction test cases + CASE_CONSTRUCT = { + # / + 'oblique small-caps bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'normal small-caps bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), + 'oblique normal bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'oblique small-caps normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'normal small-caps normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), + 'oblique normal normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'normal normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), + 'normal normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), + 'normal normal 900 1.2em/3 Ahem': ('normal', 'normal', '900', '1.2em', '3', ['Ahem']), + } + CASE_CONSTRUCT_FAMILY = { + 'Ahem': ['Ahem'], + 'Ahem, "White Space"': ['Ahem', 'White Space'], + 'Ahem, "White Space", serif': ['Ahem', 'White Space', 'serif'], + } + + @staticmethod + def tuple_to_font_dict(tup): + """Helper to convert a tuple to a font dict to check for valid outputs.""" + font_dict = {} + for idx, key in enumerate(('font_style', 'font_variant', 'font_weight', + 'font_size', 'line_height', 'font_family')): + font_dict[key] = tup[idx] + + return font_dict + + def test_parse_font_shorthand(self): + for cases in [self.CASE_5, self.CASE_4, self.CASE_3, self.CASE_2, self.CASE_1, self.CASE_EXTRAS]: + for case in sorted(cases): + expected_output = self.tuple_to_font_dict(cases[case]) + font = parse_font(case) + self.assertEqual(font, expected_output) def test_parse_font_shorthand_invalid(self): - # This font string has too many parts - with self.assertRaises(ValidationError): - parse_font(r'normal normal normal normal 12px/12px serif') - - # This invalid single part - for part in ['normal', 'foobar']: - with self.assertRaises(ValidationError): - parse_font(part) - - def test_parse_font_part(self): - pass - # - / - # - / - # - / - # - / - - def test_parse_font(self): - pass - # 5 parts with line height - # - / - # - / - # - / - # - / - # - / - # - / - - # 5 parts - # - - # - - # - - # - - # - - # - - - # 4 parts with height - # - / - # - / - # - / - # - / - # - / - # - / - - # 4 parts - # - - # - - # - - # - - # - - # - - - # 3 parts with height - # - / - # - / - # - / - - # 3 parts with height - # - - # - - # - - - # 2 parts with height - # - / - - # 2 parts - # - - - # 1 part + for cases in [self.CASE_5_INVALID, self.CASE_4_INVALID, self.CASE_3_INVALID, self.CASE_2_INVALID, + self.CASE_1_INVALID, self.CASE_EXTRAS_INVALID]: + for case in cases: + with self.assertRaises(ValueError): + parse_font(case) + + def test_construct_font_shorthand(self): + for expected_output, tup in sorted(self.CASE_CONSTRUCT.items()): + case = self.tuple_to_font_dict(tup) + font = construct_font(case) + self.assertEqual(font, expected_output) + + def test_construct_font_family(self): + for expected_output, case in sorted(self.CASE_CONSTRUCT_FAMILY.items()): + font_family = construct_font_family(case) + self.assertEqual(font_family, expected_output) diff --git a/tests/test_validators.py b/tests/test_validators.py index 2d38f7a2b..2db73d8ab 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -47,6 +47,10 @@ def test_font_family_name_valid(self): self.assertEqual(is_font_family('Ahem, serif'), ['Ahem', 'serif']) self.assertEqual(is_font_family("Ahem,fantasy"), ["Ahem", 'fantasy']) self.assertEqual(is_font_family(" Ahem , fantasy "), ["Ahem", 'fantasy']) + self.assertEqual(is_font_family("Ahem,'White Space'"), ["Ahem", 'White Space']) + self.assertEqual(is_font_family("Ahem, 'White Space'"), ["Ahem", 'White Space']) + self.assertEqual(is_font_family(" Ahem , ' White Space ' "), ["Ahem", 'White Space']) + self.assertEqual(is_font_family(" Ahem , \" White Space \" "), ["Ahem", 'White Space']) def test_font_family_name_invalid(self): invalid_cases = [ @@ -57,6 +61,7 @@ def test_font_family_name_invalid(self): '#POUND, sans-serif', 'Hawaii 5-0, sans-serif', '123', + 'ThisIsDefintelyNotAFont' ] for case in invalid_cases: with self.assertRaises(ValidationError): From 8e40d6de0b2b214f68febe45373397682c761208 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:15:57 -0500 Subject: [PATCH 20/32] Fix code style --- colosseum/parser.py | 3 --- colosseum/validators.py | 2 +- tests/test_declaration.py | 2 +- tests/test_parser.py | 6 ++---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/colosseum/parser.py b/colosseum/parser.py index eaeb24b8b..c9979cfdb 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -1,5 +1,3 @@ -import ast - from .colors import NAMED_COLOR, hsl, rgb from .exceptions import ValidationError from .fonts import get_system_font @@ -151,7 +149,6 @@ def _parse_font_property_part(value, font_dict): except (ValidationError, ValueError): pass - if '/' in value: # Maybe it is a font size with line height font_dict['font_size'], font_dict['line_height'] = value.split('/') diff --git a/colosseum/validators.py b/colosseum/validators.py index 41dd72428..030c1a1f9 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -158,7 +158,7 @@ def is_font_family(value): invalid = set(values) - set(checked_values) error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) raise exceptions.ValidationError(error_msg) - + return checked_values diff --git a/tests/test_declaration.py b/tests/test_declaration.py index d331ca35f..230209f52 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -839,7 +839,7 @@ def test_font_family_property(self): node.style.font_variant = 'small-caps' node.style.font_size = '10px' node.style.line_height = '1.5' - node.style.font_family = ['White Space', 'serif'] + node.style.font_family = ['White Space', 'serif'] expected_font = { 'font_style': 'italic', 'font_weight': 'bold', diff --git a/tests/test_parser.py b/tests/test_parser.py index 98265c635..4766e855f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,8 +2,6 @@ from colosseum import parser from colosseum.colors import hsl, rgb -from colosseum.constants import SYSTEM_FONT_KEYWORDS -from colosseum.exceptions import ValidationError from colosseum.parser import construct_font, construct_font_family, parse_font from colosseum.units import ( ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw, @@ -675,8 +673,8 @@ class ParseFontTests(ColosseumTestCase): def tuple_to_font_dict(tup): """Helper to convert a tuple to a font dict to check for valid outputs.""" font_dict = {} - for idx, key in enumerate(('font_style', 'font_variant', 'font_weight', - 'font_size', 'line_height', 'font_family')): + for idx, key in enumerate(['font_style', 'font_variant', 'font_weight', + 'font_size', 'line_height', 'font_family']): font_dict[key] = tup[idx] return font_dict From 2fb581568366b62ec83e77cdc28243168029b00f Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:23:10 -0500 Subject: [PATCH 21/32] Fix tests --- colosseum/declaration.py | 3 ++- tests/test_declaration.py | 9 ++++++--- tests/test_fonts.py | 5 ++++- tests/utils.py | 1 + 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 7f8895863..b726f91a3 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -74,7 +74,8 @@ def _add_quotes(values): """Add quotes to items that contain spaces.""" quoted_values = [] for value in values: - if ' ' in value: + if (' ' in value and not (value.startswith('"') and value.endswith('"')) + and not (value.startswith("'") and value.endswith("'"))): value = '"{value}"'.format(value=value) quoted_values.append(value) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 230209f52..135d342a3 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -456,9 +456,12 @@ def test_list_property(self): node.style.font_family = ' Ahem , serif ' self.assertEqual(node.style.font_family, ['Ahem', 'serif']) - # Check invalid values - with self.assertRaises(ValueError): - node.style.font_family = ['DejaVu Sans'] # Should have additional quotes + # Check valid value without extra quotes + node.style.font_family = ['White Space'] + + # Check extra quotes are removed + node.style.font_family = ['"White Space"'] + self.assertEqual(node.style.font_family, ['White Space']) # Check the error message try: diff --git a/tests/test_fonts.py b/tests/test_fonts.py index fa745e706..f0e141004 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -8,7 +8,6 @@ class ParseFontTests(ColosseumTestCase): def test_font_database(self): # Check empty cache - FontDatabase.clear_cache() self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noqa # Check populated cache @@ -16,6 +15,10 @@ def test_font_database(self): FontDatabase.validate_font_family('White Space') self.assertEqual(FontDatabase._FONTS_CACHE, {'Ahem': None, 'White Space': None}) # noqa + # Check clear cache + FontDatabase.clear_cache() + self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noq + # Check returns a string self.assertTrue(bool(FontDatabase.fonts_path(system=True))) self.assertTrue(bool(FontDatabase.fonts_path(system=False))) diff --git a/tests/utils.py b/tests/utils.py index a8028ff59..f1228a16d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -216,6 +216,7 @@ def copy_fonts(self): try: FontDatabase.validate_font_family('Ahem') + FontDatabase.clear_cache() except ValidationError: raise Exception('\n\nTesting fonts (Ahem & Ahem Extra) are not active.\n' '\nPlease run the test suite one more time.\n') From be720244e2e363ff80ea28ec485965e733f8d6c2 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:24:28 -0500 Subject: [PATCH 22/32] Fix tests --- colosseum/declaration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/colosseum/declaration.py b/colosseum/declaration.py index b726f91a3..e20be59bc 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -75,7 +75,7 @@ def _add_quotes(values): quoted_values = [] for value in values: if (' ' in value and not (value.startswith('"') and value.endswith('"')) - and not (value.startswith("'") and value.endswith("'"))): + and not (value.startswith("'") and value.endswith("'"))): value = '"{value}"'.format(value=value) quoted_values.append(value) From 91b816d971f24cb62f9ce96cdc5f9761fe43b644 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:48:46 -0500 Subject: [PATCH 23/32] More tests --- colosseum/parser.py | 17 +++++++++----- tests/test_fonts.py | 1 + tests/test_parser.py | 56 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/colosseum/parser.py b/colosseum/parser.py index c9979cfdb..32f0ea060 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -145,7 +145,7 @@ def _parse_font_property_part(value, font_dict): try: value = choices.validate(value) font_dict[property_name] = value - return font_dict + return font_dict, False except (ValidationError, ValueError): pass @@ -154,19 +154,19 @@ def _parse_font_property_part(value, font_dict): font_dict['font_size'], font_dict['line_height'] = value.split('/') FONT_SIZE_CHOICES.validate(font_dict['font_size']) LINE_HEIGHT_CHOICES.validate(font_dict['line_height']) - return font_dict + return font_dict, True else: # Or just a font size try: FONT_SIZE_CHOICES.validate(value) font_dict['font_size'] = value - return font_dict + return font_dict, True except ValueError: pass raise ValidationError('Font value "{value}" not valid!'.format(value=value)) - return font_dict + return font_dict, False def parse_font(string): @@ -187,7 +187,7 @@ def parse_font(string): # Remove extra spaces string = ' '.join(str(string).strip().split()) - + print('\n' + string) parts = string.split(' ', 1) if len(parts) == 1: # If font is specified as a system keyword, it must be one of: @@ -216,10 +216,15 @@ def parse_font(string): # We iteratively split by the first left hand space found and try to validate if that part # is a valid or or (which can come in any order) # or / (which has to come after all the other properties) + old_is_font_size = False # Need to check that some properties come after font-size for _ in range(5): value = parts[0] try: - font_dict = _parse_font_property_part(value, font_dict) + font_dict, is_font_size = _parse_font_property_part(value, font_dict) + print(old_is_font_size, is_font_size, font_dict) + if is_font_size is False and old_is_font_size: + raise ValueError('TODO') + old_is_font_size = is_font_size parts = parts[-1].split(' ', 1) except ValidationError: break diff --git a/tests/test_fonts.py b/tests/test_fonts.py index f0e141004..fbdb5971e 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -8,6 +8,7 @@ class ParseFontTests(ColosseumTestCase): def test_font_database(self): # Check empty cache + FontDatabase.clear_cache() self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noqa # Check populated cache diff --git a/tests/test_parser.py b/tests/test_parser.py index 4766e855f..cd95e55a3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -627,27 +627,65 @@ class ParseFontTests(ColosseumTestCase): ]) CASE_1_INVALID = set([ # | inherit - 'Ahem' - '"White Space"' + 'Ahem', '', '20', 20, ]) CASE_EXTRAS_INVALID = set([ # Space between font-size and line-height - 'small-caps oblique normal 1.2em /3 Ahem' - 'small-caps oblique normal 1.2em/ 3 Ahem' - 'small-caps oblique normal 1.2em / 3 Ahem' + 'small-caps oblique normal 1.2em /3 Ahem', + 'small-caps oblique normal 1.2em/ 3 Ahem', + 'small-caps oblique normal 1.2em / 3 Ahem', # Too many parts - 'normal normal normal normal 12px/12px serif' - 'normal normal normal normal normal 12px/12px serif' + 'normal normal normal normal 12px/12px serif', + 'normal normal normal normal normal 12px/12px serif', # No quotes with spaces - 'small-caps oblique normal 1.2em/3 Ahem, White Space' + 'small-caps oblique normal 1.2em/3 Ahem, White Space', # No commas - 'small-caps oblique normal 1.2em/3 Ahem "White Space"' + 'small-caps oblique normal 1.2em/3 Ahem "White Space"', + + # Incorrect order + '1.2em/3 small-caps oblique bold Ahem', + '1.2em/3 oblique bold small-caps Ahem', + '1.2em/3 bold small-caps oblique Ahem', + '1.2em/3 small-caps bold oblique Ahem', + + '1.2em small-caps oblique bold Ahem', + '1.2em oblique bold small-caps Ahem', + '1.2em bold small-caps oblique Ahem', + '1.2em small-caps bold oblique Ahem', + + 'small-caps 1.2em/3 oblique bold Ahem', + 'small-caps 1.2em/3 bold oblique Ahem', + 'bold 1.2em/3 small-caps oblique Ahem', + 'bold 1.2em/3 oblique small-caps Ahem', + 'oblique 1.2em/3 small-caps bold Ahem', + 'oblique 1.2em/3 bold small-caps Ahem', + + 'small-caps 1.2em oblique bold Ahem', + 'small-caps 1.2em bold oblique Ahem', + 'bold 1.2em small-caps oblique Ahem', + 'bold 1.2em oblique small-caps Ahem', + 'oblique 1.2em small-caps bold Ahem', + 'oblique 1.2em bold small-caps Ahem', + + 'small-caps bold 1.2em/3 oblique Ahem', + 'bold small-caps 1.2em/3 oblique Ahem', + 'oblique small-caps 1.2em/3 bold Ahem', + 'small-caps oblique 1.2em/3 bold Ahem', + 'oblique bold 1.2em/3 small-caps Ahem', + 'bold oblique 1.2em/3 small-caps Ahem', + + 'normal normal 1.2em/3 normal Ahem', + 'normal 1.2em/3 normal normal Ahem', + 'normal 1.2em/3 normal Ahem', + '1.2em/3 normal Ahem', + '1.2em/3 normal normal Ahem', + '1.2em/3 normal normal normal Ahem', ]) # Font construction test cases From 1e496c6058f3c1d7c820508757e2ff41923e48d2 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 19:55:32 -0500 Subject: [PATCH 24/32] Remove print statements --- colosseum/parser.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/colosseum/parser.py b/colosseum/parser.py index 32f0ea060..23120a8f8 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -187,7 +187,6 @@ def parse_font(string): # Remove extra spaces string = ' '.join(str(string).strip().split()) - print('\n' + string) parts = string.split(' ', 1) if len(parts) == 1: # If font is specified as a system keyword, it must be one of: @@ -221,7 +220,6 @@ def parse_font(string): value = parts[0] try: font_dict, is_font_size = _parse_font_property_part(value, font_dict) - print(old_is_font_size, is_font_size, font_dict) if is_font_size is False and old_is_font_size: raise ValueError('TODO') old_is_font_size = is_font_size From 382e7581d867bd50d1f2ef16b2d2afe338d8efb7 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Fri, 10 Jan 2020 20:02:31 -0500 Subject: [PATCH 25/32] Update CI config and add exception message for incorrect font shorthand order --- .github/workflows/ci.yml | 2 +- colosseum/parser.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9cfa6109..16a5e6d8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: beefore --username github-actions --repository ${{ github.repository }} --pull-request ${{ github.event.number }} --commit ${{ github.event.pull_request.head.sha }} ${{ matrix.task }} . smoke: - name: Smoke test (Linux) + name: Smoke test (Linux) (3.7) needs: beefore runs-on: ubuntu-latest steps: diff --git a/colosseum/parser.py b/colosseum/parser.py index 23120a8f8..08567e952 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -221,7 +221,8 @@ def parse_font(string): try: font_dict, is_font_size = _parse_font_property_part(value, font_dict) if is_font_size is False and old_is_font_size: - raise ValueError('TODO') + raise ValueError('Font property shorthand does not follow the correct order!' + ' or or must come before ') old_is_font_size = is_font_size parts = parts[-1].split(' ', 1) except ValidationError: From 8b3b6f10a75f0618a9e4ca1fc07c581370c32f6a Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 15 Jan 2020 21:47:45 -0500 Subject: [PATCH 26/32] Add font shorthand wrapers and update tests --- .github/workflows/ci.yml | 28 +- colosseum/constants.py | 3 +- colosseum/declaration.py | 66 ++-- colosseum/fonts.py | 59 ++- colosseum/parser.py | 68 ++-- colosseum/validators.py | 6 +- colosseum/wrappers.py | 147 ++++++++ tests/test_declaration.py | 105 ++---- tests/test_fonts.py | 10 +- tests/test_parser.py | 746 +++++++++++--------------------------- tests/test_wrappers.py | 136 +++++++ tests/utils.py | 14 +- 12 files changed, 666 insertions(+), 722 deletions(-) create mode 100644 colosseum/wrappers.py create mode 100644 tests/test_wrappers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16a5e6d8a..5de868a1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: task: ['pycodestyle'] steps: - uses: actions/checkout@v1 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | pip install --upgrade pip setuptools beefore @@ -25,26 +25,26 @@ jobs: beefore --username github-actions --repository ${{ github.repository }} --pull-request ${{ github.event.number }} --commit ${{ github.event.pull_request.head.sha }} ${{ matrix.task }} . smoke: - name: Smoke test (Linux) (3.7) + name: Smoke test (Linux) (3.5) needs: beefore runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Set up Python 3.7 + - name: Set up Python 3.5 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.5 - name: Install dependencies run: | sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config - pip install --upgrade pip setuptools pytest-tldr + pip install --upgrade pip setuptools pytest pytest-tldr pip install -e . - name: Install fonts run: | xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | - xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ python-versions: name: Python compatibility test (Linux) @@ -53,7 +53,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.5, 3.6] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} @@ -63,14 +63,14 @@ jobs: - name: Install dependencies run: | sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config - pip install --upgrade pip setuptools + pip install --upgrade pip setuptools pytest pytest-tldr pip install -e . - name: Install fonts run: | xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | - xvfb-run -a -s '-screen 0 2048x1536x24' python setup.py test + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ windows: name: Windows tests @@ -84,14 +84,14 @@ jobs: python-version: 3.5 - name: Install dependencies run: | - pip install --upgrade pip setuptools + pip install --upgrade pip setuptools pytest pytest-tldr pip install -e . - name: Install fonts run: | python tests/utils.py - name: Test run: | - python setup.py test + pytest tests/ macOS: name: macOS tests @@ -105,11 +105,11 @@ jobs: python-version: 3.5 - name: Install dependencies run: | - pip install --upgrade pip setuptools + pip install --upgrade pip setuptools pytest pytest-tldr pip install -e . - name: Install fonts run: | python tests/utils.py - name: Test run: | - python setup.py test + pytest tests/ diff --git a/colosseum/constants.py b/colosseum/constants.py index a656d647c..dbb0595b5 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,5 +1,6 @@ from .exceptions import ValidationError from .validators import is_color, is_font_family, is_integer, is_length, is_number, is_percentage +from .wrappers import FontFamily class Choices: @@ -443,7 +444,7 @@ def __str__(self): 'font_weight': NORMAL, 'font_size': MEDIUM, 'line_height': NORMAL, - 'font_family': [INITIAL], # TODO: Depends on user agent. What to use? + 'font_family': FontFamily([INITIAL]), # TODO: Depends on user agent. What to use? } ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index e20be59bc..81b26cbf0 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -1,3 +1,5 @@ +import collections + from . import engine as css_engine from .constants import ( # noqa ALIGN_CONTENT_CHOICES, ALIGN_ITEMS_CHOICES, ALIGN_SELF_CHOICES, AUTO, @@ -9,33 +11,33 @@ FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, GRID_AUTO_CHOICES, GRID_AUTO_FLOW_CHOICES, GRID_GAP_CHOICES, GRID_PLACEMENT_CHOICES, GRID_TEMPLATE_AREA_CHOICES, GRID_TEMPLATE_CHOICES, - INITIAL, INITIAL_FONT_VALUES, INLINE, JUSTIFY_CONTENT_CHOICES, - LINE_HEIGHT_CHOICES, LTR, MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, - MIN_SIZE_CHOICES, NORMAL, NOWRAP, ORDER_CHOICES, PADDING_CHOICES, - POSITION_CHOICES, ROW, SIZE_CHOICES, STATIC, STRETCH, TRANSPARENT, - UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, VISIBLE, Z_INDEX_CHOICES, - default, + INITIAL, INLINE, JUSTIFY_CONTENT_CHOICES, LINE_HEIGHT_CHOICES, LTR, + MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, MIN_SIZE_CHOICES, NORMAL, NOWRAP, + ORDER_CHOICES, PADDING_CHOICES, POSITION_CHOICES, ROW, SIZE_CHOICES, + STATIC, STRETCH, TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, + VISIBLE, Z_INDEX_CHOICES, default, ) from .exceptions import ValidationError -from .parser import construct_font, construct_font_family, parse_font +from .parser import parse_font +from .wrappers import FontFamily, FontShorthand _CSS_PROPERTIES = set() def validated_font_property(name, initial): """Define the shorthand CSS font property.""" - assert isinstance(initial, dict) + assert isinstance(initial, FontShorthand) initial = initial.copy() def getter(self): font = initial.copy() - for property_name in font: + for property_name in font.keys(): font[property_name] = getattr(self, property_name) return font def setter(self, value): try: - font = parse_font(value) + font = FontShorthand(**parse_font(value)) except ValidationError: raise ValueError("Invalid value '%s' for CSS property '%s'!" % (value, name)) @@ -53,7 +55,7 @@ def deleter(self): # Attribute doesn't exist pass - for property_name in INITIAL_FONT_VALUES: + for property_name in initial: try: delattr(self, property_name) self.dirty = True @@ -67,8 +69,6 @@ def deleter(self): def validated_list_property(name, choices, initial, separator=',', add_quotes=False): """Define a property holding a list values.""" - if not isinstance(initial, list): - raise ValueError('Initial value must be a list!') def _add_quotes(values): """Add quotes to items that contain spaces.""" @@ -82,15 +82,21 @@ def _add_quotes(values): return quoted_values def getter(self): - return getattr(self, '_%s' % name, initial).copy() + # We return an immutable list subclass + return getattr(self, '_%s' % name, initial) def setter(self, value): - if not isinstance(value, str): + if isinstance(value, collections.Sequence) and not isinstance(value, str): if add_quotes: value = _add_quotes(value) value = separator.join(value) + elif isinstance(value, str): + pass + else: + raise ValueError('Invalid value for CSS property!') + try: - # The validator must validate all the values at once + # The validator must validate the string version (all values at once) # because the order of parameters might be important. values = choices.validate(value) @@ -101,11 +107,9 @@ def setter(self, value): value, name, choices )) - if not isinstance(values, list): - values.split(separator) - - if values != getattr(self, '_%s' % name, initial): - setattr(self, '_%s' % name, values[:]) + if values != getattr(self, '_%s' % name, list(initial)): + # We use the initial type to make an instance with the set values + setattr(self, '_%s' % name, initial.__class__(values)) self.dirty = True def deleter(self): @@ -378,7 +382,7 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=[INITIAL], + font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=FontFamily([INITIAL]), add_quotes=True) # 15.4 Font Styling @@ -394,7 +398,7 @@ def __init__(self, **style): font_size = validated_property('font_size', choices=FONT_SIZE_CHOICES, initial=MEDIUM) # 15.8 Shorthand font property - font = validated_font_property('font', initial=INITIAL_FONT_VALUES) + font = validated_font_property('font', initial=FontShorthand()) # 16. Text ########################################################### # 16.1 Indentation @@ -606,16 +610,18 @@ def keys(self): ###################################################################### def __str__(self): non_default = [] + + # Other shorthand properties have to be included here + shorthand_properties = ['border', 'border_top', 'border_right', 'border_bottom', 'border_left', 'font'] + for name in _CSS_PROPERTIES: - if name == 'font': + # Shorthand values are constructed on demand since they depend on + # other properties, we have to use getattr(self, name) instead of + # getattr(self, '_%s' % name) + if name in shorthand_properties: try: if getattr(self, '_%s' % name, None): - non_default.append((name, construct_font(getattr(self, name)))) - except AttributeError: - pass - elif name == 'font_family': - try: - non_default.append((name.replace('_', '-'), construct_font_family(getattr(self, '_%s' % name)))) + non_default.append((name, getattr(self, name))) except AttributeError: pass else: diff --git a/colosseum/fonts.py b/colosseum/fonts.py index 40544b371..db9d9dc9c 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -25,8 +25,9 @@ def validate_font_family(cls, value): if value in cls._FONTS_CACHE: return value else: - if check_font_family(value): - # TODO: to be filled with a font properties instance + font_exists = check_font_family(value) + if font_exists: + # TODO: to be filled with a cross-platform font properties instance cls._FONTS_CACHE[value] = None return value @@ -35,7 +36,7 @@ def validate_font_family(cls, value): @staticmethod def fonts_path(system=False): """Return the path for cross platform user fonts.""" - if os.name == 'nt': + if sys.platform == 'win32': import winreg if system: fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%windir%'), 'Fonts') @@ -59,38 +60,34 @@ def fonts_path(system=False): def _check_font_family_mac(value): - """List available font family names on mac.""" + """Get font by family name and size on mac.""" from ctypes import cdll, util from rubicon.objc import ObjCClass appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa - NSFontManager = ObjCClass("NSFontManager") - NSFontManager.declare_class_property('sharedFontManager') - NSFontManager.declare_property("availableFontFamilies") - manager = NSFontManager.sharedFontManager - for item in manager.availableFontFamilies: - font_name = str(item) - if font_name == value: - return True - - return False + NSFont = ObjCClass('NSFont') + return bool(NSFont.fontWithName(value, size=0)) # size=0 returns defautl size def _check_font_family_linux(value): """List available font family names on linux.""" import gi # noqa gi.require_version("Gtk", "3.0") + gi.require_version("Pango", "1.0") from gi.repository import Gtk # noqa + from gi.repository import Pango # noqa class Window(Gtk.Window): """Use Pango to get system fonts names.""" - def check_system_font(self, value): - """Check if font family exists on system.""" + def get_font(self, value): + """Get font from the system.""" context = self.create_pango_context() - for font_family in context.list_families(): - font_name = font_family.get_name() - if font_name == value: - return True + font = context.load_font(Pango.FontDescription(value)) + + # Pango always loads something close to the requested so we need to check + # the actual loaded font is the requested one. + if font.describe().to_string().startswith(value): + return True # TODO: Wrap on a font cross platform wrapper return False @@ -104,18 +101,20 @@ def check_system_font(self, value): def _check_font_family_win(value): """List available font family names on windows.""" import winreg # noqa + + font_name = value + ' (TrueType)' # TODO: check other options + font = False + key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" for base in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: - key = winreg.OpenKey(base, - r"Software\Microsoft\Windows NT\CurrentVersion\Fonts", - 0, - winreg.KEY_READ) - for idx in range(0, winreg.QueryInfoKey(key)[1]): - font_name = winreg.EnumValue(key, idx)[0] - font_name = font_name.replace(' (TrueType)', '') - if font_name == value: + with winreg.OpenKey(base, key_path, 0, winreg.KEY_READ) as reg_key: + try: + # Query if it exists + font = winreg.QueryValueEx(reg_key, font_name) return True + except FileNotFoundError: + pass - return False + return font def check_font_family(value): @@ -124,7 +123,7 @@ def check_font_family(value): return _check_font_family_mac(value) elif sys.platform.startswith('linux'): return _check_font_family_linux(value) - elif os.name == 'nt': + elif sys.platform == 'win32': return _check_font_family_win(value) else: raise NotImplementedError('Cannot check font existence on this system!') diff --git a/colosseum/parser.py b/colosseum/parser.py index 08567e952..01055b46a 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -2,6 +2,7 @@ from .exceptions import ValidationError from .fonts import get_system_font from .units import Unit, px +from .wrappers import FontFamily def units(value): @@ -134,20 +135,42 @@ def color(value): # Font handling ############################################################################## def _parse_font_property_part(value, font_dict): - """Parse font shorthand property part for known properties.""" + """ + Parse font shorthand property part for known properties. + + `value` corresponds to a piece (or part) of a font shorthand property that can + look like: + - ' / ... + - '/ ...' + - ' / ...' + - ... + + Each part can then correspond to one of these values: + , , , / + + The `font_dict` keeps track fo parts that have been already parse so that we + can check that a part is duplicated like: + - ' /' + """ from .constants import (FONT_SIZE_CHOICES, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, FONT_WEIGHT_CHOICES, LINE_HEIGHT_CHOICES, NORMAL) - font_dict = font_dict.copy() if value != NORMAL: for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, 'font_weight': FONT_WEIGHT_CHOICES, 'font_style': FONT_STYLE_CHOICES}.items(): try: value = choices.validate(value) - font_dict[property_name] = value - return font_dict, False except (ValidationError, ValueError): - pass + continue + + # If a property has been already parsed, finding the same property is an error + if property_name in font_dict: + raise ValueError('Font value "{value}" includes several "{property_name}" values!' + ''.format(value=value, property_name=property_name)) + + font_dict[property_name] = value + + return font_dict, False if '/' in value: # Maybe it is a font size with line height @@ -183,7 +206,6 @@ def parse_font(string): - https://developer.mozilla.org/en-US/docs/Web/CSS/font """ from .constants import INHERIT, INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS, FONT_FAMILY_CHOICES # noqa - font_dict = INITIAL_FONT_VALUES.copy() # Remove extra spaces string = ' '.join(str(string).strip().split()) @@ -212,17 +234,20 @@ def parse_font(string): # - line-height must immediately follow font-size, preceded by "/", like this: "16px/3" # - font-family must be the last value specified. + # Need to check that some properties come after font-size + old_is_font_size = False + font_dict = {} + # We iteratively split by the first left hand space found and try to validate if that part # is a valid or or (which can come in any order) # or / (which has to come after all the other properties) - old_is_font_size = False # Need to check that some properties come after font-size for _ in range(5): value = parts[0] try: font_dict, is_font_size = _parse_font_property_part(value, font_dict) if is_font_size is False and old_is_font_size: raise ValueError('Font property shorthand does not follow the correct order!' - ' or or must come before ') + ', and must come before ') old_is_font_size = is_font_size parts = parts[-1].split(' ', 1) except ValidationError: @@ -233,28 +258,9 @@ def parse_font(string): raise ValueError('Font property shorthand contains too many parts!') value = ' '.join(parts) - font_dict['font_family'] = FONT_FAMILY_CHOICES.validate(value) - - return font_dict - - -def construct_font(font_dict): - """Construct font property string from a dictionary of font properties.""" - font_dict_copy = font_dict.copy() - font_dict_copy['font_family'] = construct_font_family(font_dict_copy['font_family']) - - return ('{font_style} {font_variant} {font_weight} ' - '{font_size}/{line_height} {font_family}').format(**font_dict_copy) - - -def construct_font_family(font_family): - """Construct a font family property from a list of font families.""" - assert isinstance(font_family, list) - checked_font_family = [] - for family in font_family: - if ' ' in family: - family = '"{value}"'.format(value=family) + font_dict['font_family'] = FontFamily(FONT_FAMILY_CHOICES.validate(value)) - checked_font_family.append(family) + full_font_dict = INITIAL_FONT_VALUES.copy() + full_font_dict.update(font_dict) - return ', '.join(checked_font_family) + return full_font_dict diff --git a/colosseum/validators.py b/colosseum/validators.py index 030c1a1f9..9c97fc0e9 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -119,7 +119,7 @@ def is_font_family(value): This validator returns a list. """ from .constants import GENERIC_FAMILY_FONTS as generic_family - font_database = fonts.FontDatabase + FontDatabase = fonts.FontDatabase # Remove extra outer spaces font_value = ' '.join(value.strip().split()) @@ -139,7 +139,7 @@ def is_font_family(value): except ValueError: raise exceptions.ValidationError - if not font_database.validate_font_family(no_quotes_val): + if not FontDatabase.validate_font_family(no_quotes_val): raise exceptions.ValidationError('Font family "{font_value}"' ' not found on system!'.format(font_value=no_quotes_val)) checked_values.append(no_quotes_val) @@ -148,7 +148,7 @@ def is_font_family(value): else: error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=val) if _CSS_IDENTIFIER_RE.match(val): - if not font_database.validate_font_family(val): + if not FontDatabase.validate_font_family(val): raise exceptions.ValidationError(error_msg) checked_values.append(val) else: diff --git a/colosseum/wrappers.py b/colosseum/wrappers.py new file mode 100644 index 000000000..9956e9455 --- /dev/null +++ b/colosseum/wrappers.py @@ -0,0 +1,147 @@ +from collections.abc import Sequence + + +class ImmutableList(Sequence): + + def __init__(self, iterable=()): + self._data = tuple(iterable) + + def _get_error_message(self, err): + return str(err).replace('list', self.__class__.__name__, 1) + + def __eq__(self, other): + return other.__class__ == self.__class__ and self._data == other._data + + def __getitem__(self, index): + try: + return self._data[index] + except Exception as err: + error_msg = self._get_error_message(err) + raise err.__class__(error_msg) + + def __len__(self): + return len(self._data) + + def __hash__(self): + return hash((self.__class__.__name__, self._data)) + + def __repr__(self): + class_name = self.__class__.__name__ + if len(self._data) > 1: + text = '{class_name}([{data}])'.format(data=str(self._data)[1:-1], class_name=class_name) + elif len(self._data) == 1: + text = '{class_name}([{data}])'.format(data=str(self._data)[1:-2], class_name=class_name) + else: + text = '{class_name}()'.format(class_name=class_name) + return text + + def __str__(self): + return repr(self) + + def copy(self): + return self.__class__(self._data) + + +class FontFamily(ImmutableList): + """List like wrapper to hold font families.""" + + def __init__(self, iterable=()): + try: + super().__init__(iterable) + except Exception as err: + error_msg = self._get_error_message(err) + raise err.__class__(error_msg) + self._check_values(list(iterable)) + + def _check_values(self, values): + if not isinstance(values, list): + values = [values] + + for value in values: + if not isinstance(value, str): + raise TypeError('Invalid argument for font family') + + def __str__(self): + items = [] + for item in self._data: + if ' ' in item: + item = '"{item}"'.format(item=item) + items.append(item) + return ', '.join(items) + + +class Shorthand: + """ + Dictionary-like wrapper to hold shorthand data. + + This class is not iterable and should be subclassed + """ + VALID_KEYS = [] + + def __init__(self, **kwargs): + self._map = kwargs + + def __eq__(self, other): + return other.__class__ == self.__class__ and self._map == other._map + + def __setitem__(self, key, value): + if self.VALID_KEYS: + if key in self.VALID_KEYS: + self._map[key] = value + else: + raise KeyError('Valid keys are: {keys}'.format(keys=self.VALID_KEYS)) + else: + self._map[key] = value + + def __getitem__(self, key): + if self.VALID_KEYS: + if key in self.VALID_KEYS: + return self._map[key] + else: + return self._map[key] + + raise KeyError('Valid keys are: {keys}'.format(keys=self.VALID_KEYS)) + + def __repr__(self): + map_copy = self._map.copy() + items = [] + for key in self.VALID_KEYS: + items.append("{key}={value}".format(key=key, value=repr(map_copy[key]))) + + class_name = self.__class__.__name__ + string = "{class_name}({items})".format(class_name=class_name, items=', '.join(items)) + return string.format(**map_copy) + + def __len__(self): + return len(self._map) + + def __str__(self): + return repr(self) + + def keys(self): + return self._map.keys() + + def items(self): + return self._map.items() + + def copy(self): + return self.__class__(**self._map) + + def to_dict(self): + return self._map.copy() + + +class FontShorthand(Shorthand): + """Dictionary-like wrapper to hold font shorthand property.""" + VALID_KEYS = ['font_style', 'font_variant', 'font_weight', 'font_size', 'line_height', 'font_family'] + + def __init__(self, font_style='normal', font_variant='normal', font_weight='normal', + font_size='medium', line_height='normal', font_family=FontFamily(['initial'])): + super().__init__( + font_style=font_style, font_variant=font_variant, font_weight=font_weight, + font_size=font_size, line_height=line_height, font_family=font_family, + ) + + def __str__(self): + string = '{font_style} {font_variant} {font_weight} {font_size}/{line_height} {font_family}' + return string.format(**self._map) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 135d342a3..ebe441e1d 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -11,6 +11,7 @@ from colosseum.validators import ( is_color, is_integer, is_length, is_number, is_percentage, ) +from colosseum.wrappers import FontFamily, FontShorthand, ImmutableList from .utils import ColosseumTestCase, TestNode @@ -444,7 +445,8 @@ def test_list_property(self): node.layout.dirty = None # Check initial value - self.assertEqual(node.style.font_family, ['initial']) + print(node.style.font_family) + self.assertEqual(node.style.font_family, FontFamily(['initial'])) # Check valid values node.style.font_family = ['serif'] @@ -452,16 +454,16 @@ def test_list_property(self): # This will coerce to a list, is this a valid behavior? node.style.font_family = 'Ahem' - self.assertEqual(node.style.font_family, ['Ahem']) + self.assertEqual(node.style.font_family, FontFamily(['Ahem'])) node.style.font_family = ' Ahem , serif ' - self.assertEqual(node.style.font_family, ['Ahem', 'serif']) + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'serif'])) # Check valid value without extra quotes node.style.font_family = ['White Space'] # Check extra quotes are removed node.style.font_family = ['"White Space"'] - self.assertEqual(node.style.font_family, ['White Space']) + self.assertEqual(node.style.font_family, FontFamily(['White Space'])) # Check the error message try: @@ -707,7 +709,8 @@ def test_font_shorthand_property(self): node.layout.dirty = None # Check initial value - self.assertEqual(node.style.font, INITIAL_FONT_VALUES) + self.assertTrue(isinstance(node.style.font, FontShorthand)) + self.assertEqual(node.style.font.to_dict(), INITIAL_FONT_VALUES) # Check Initial values self.assertEqual(node.style.font_style, 'normal') @@ -715,7 +718,7 @@ def test_font_shorthand_property(self): self.assertEqual(node.style.font_variant, 'normal') self.assertEqual(node.style.font_size, 'medium') self.assertEqual(node.style.line_height, 'normal') - self.assertEqual(node.style.font_family, ['initial']) + self.assertEqual(node.style.font_family, FontFamily(['initial'])) # Check individual properties update the unset shorthand node.style.font_style = 'italic' @@ -730,9 +733,9 @@ def test_font_shorthand_property(self): 'font_variant': 'small-caps', 'font_size': '10px', 'line_height': '1.5', - 'font_family': ['Ahem', 'serif'], + 'font_family': FontFamily(['Ahem', 'serif']), } - font = node.style.font + font = node.style.font.to_dict() font['font_size'] = str(font['font_size']) font['line_height'] = str(font['line_height']) self.assertEqual(font, expected_font) @@ -744,7 +747,7 @@ def test_font_shorthand_property(self): self.assertEqual(node.style.font_variant, 'normal') self.assertEqual(node.style.line_height, 'normal') self.assertEqual(str(node.style.font_size), '9px') - self.assertEqual(node.style.font_family, ['serif']) + self.assertEqual(node.style.font_family, FontFamily(['serif'])) # Check individual properties do not update the set shorthand node.style.font = '9px "White Space", serif' @@ -759,9 +762,9 @@ def test_font_shorthand_property(self): 'font_variant': 'small-caps', 'font_size': '10px', 'line_height': '1.5', - 'font_family': ['White Space', 'serif'], + 'font_family': FontFamily(['White Space', 'serif']), } - font = node.style.font + font = node.style.font.to_dict() font['font_size'] = str(font['font_size']) font['line_height'] = str(font['line_height']) self.assertEqual(font, expected_font) @@ -795,67 +798,33 @@ def test_font_family_property(self): node = TestNode(style=CSS()) node.layout.dirty = None - # Check initial value - self.assertEqual(node.style.font, INITIAL_FONT_VALUES) - + # Check type + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + # Check Initial values - self.assertEqual(node.style.font_style, 'normal') - self.assertEqual(node.style.font_weight, 'normal') - self.assertEqual(node.style.font_variant, 'normal') - self.assertEqual(node.style.font_size, 'medium') - self.assertEqual(node.style.line_height, 'normal') - self.assertEqual(node.style.font_family, ['initial']) + self.assertEqual(node.style.font_family, FontFamily([INITIAL])) - # Check individual properties update the unset shorthand - node.style.font_style = 'italic' - node.style.font_weight = 'bold' - node.style.font_variant = 'small-caps' - node.style.font_size = '10px' - node.style.line_height = '1.5' - node.style.font_family = ['Ahem', 'serif'] - expected_font = { - 'font_style': 'italic', - 'font_weight': 'bold', - 'font_variant': 'small-caps', - 'font_size': '10px', - 'line_height': '1.5', - 'font_family': ['Ahem', 'serif'], - } - font = node.style.font - font['font_size'] = str(font['font_size']) - font['line_height'] = str(font['line_height']) - self.assertEqual(font, expected_font) + # Check lists as input + node.style.font_family = ['Ahem', 'White Space', 'serif'] + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'White Space', 'serif'])) - # Check setting the shorthand resets values - node.style.font = '9px serif' - self.assertEqual(node.style.font_style, 'normal') - self.assertEqual(node.style.font_weight, 'normal') - self.assertEqual(node.style.font_variant, 'normal') - self.assertEqual(node.style.line_height, 'normal') - self.assertEqual(str(node.style.font_size), '9px') - self.assertEqual(node.style.font_family, ['serif']) + # Check strings as input + node.style.font_family = 'Ahem, "White Space", serif' + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'White Space', 'serif'])) - # Check individual properties do not update the set shorthand - node.style.font = '9px "White Space", Ahem, serif' - node.style.font_style = 'italic' - node.style.font_weight = 'bold' - node.style.font_variant = 'small-caps' - node.style.font_size = '10px' - node.style.line_height = '1.5' - node.style.font_family = ['White Space', 'serif'] - expected_font = { - 'font_style': 'italic', - 'font_weight': 'bold', - 'font_variant': 'small-caps', - 'font_size': '10px', - 'line_height': '1.5', - 'font_family': ['White Space', 'serif'], - } - font = node.style.font - font['font_size'] = str(font['font_size']) - font['line_height'] = str(font['line_height']) - self.assertEqual(font, expected_font) + # Check string + self.assertEqual(str(node.style.font_family), 'Ahem, "White Space", serif') + self.assertEqual(str(node.style), 'font-family: Ahem, "White Space", serif') + + # # Check resets value + del node.style.font_family + self.assertEqual(node.style.font_family, FontFamily([INITIAL])) # Check invalid values with self.assertRaises(ValueError): - node.style.font = 'ThisIsDefinitelyNotAFontName' + node.style.font_family = 1 + + with self.assertRaises(ValueError): + node.style.font_family = {1} diff --git a/tests/test_fonts.py b/tests/test_fonts.py index fbdb5971e..29e41da87 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -1,5 +1,6 @@ +from colosseum.constants import INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS +from colosseum.exceptions import ValidationError from colosseum.fonts import FontDatabase, get_system_font -from colosseum.constants import INITIAL_FONT_VALUES from .utils import ColosseumTestCase @@ -24,5 +25,10 @@ def test_font_database(self): self.assertTrue(bool(FontDatabase.fonts_path(system=True))) self.assertTrue(bool(FontDatabase.fonts_path(system=False))) + # Check invalid + with self.assertRaises(ValidationError): + FontDatabase.validate_font_family('IAmDefinitelyNotAFontFamilyName') + def test_get_system_font(self): - self.assertEqual(get_system_font('status-bar'), INITIAL_FONT_VALUES) + for keyword in SYSTEM_FONT_KEYWORDS: + self.assertEqual(get_system_font(keyword), INITIAL_FONT_VALUES) diff --git a/tests/test_parser.py b/tests/test_parser.py index cd95e55a3..fcb4a2374 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,12 +1,15 @@ from unittest import TestCase +import pytest + from colosseum import parser from colosseum.colors import hsl, rgb -from colosseum.parser import construct_font, construct_font_family, parse_font +from colosseum.constants import INITIAL_FONT_VALUES +from colosseum.parser import parse_font +from colosseum.wrappers import FontFamily, FontShorthand from colosseum.units import ( ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw, ) - from .utils import ColosseumTestCase @@ -192,552 +195,223 @@ def test_named_color(self): parser.color('not a color') -class ParseFontTests(ColosseumTestCase): - CASE_5 = { - # / - 'oblique small-caps bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal small-caps bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'oblique normal bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'oblique small-caps normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'normal small-caps normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'oblique normal normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'normal normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'bold oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'bold normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'bold oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'bold normal normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'small-caps bold oblique 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal bold oblique 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'small-caps normal oblique 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'small-caps bold normal 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal bold normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'small-caps normal normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'normal normal oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'small-caps oblique bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal oblique bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'small-caps normal bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'small-caps oblique normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - } - CASE_4 = { - # / - 'oblique small-caps 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'oblique normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'normal small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'oblique bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - - # / - 'bold oblique 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'bold normal 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'bold small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - } - CASE_3 = { - # / - 'oblique 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'small-caps 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - - # / - 'bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), +############################################################################## +# Font tests with pytest parametrization +############################################################################## - # - 'oblique 1.2em Ahem': ('oblique', 'normal', 'normal', '1.2em', 'normal', ['Ahem']), +# Constants +EMPTY = '' +INVALID = '' - # - 'small-caps 1.2em Ahem': ('normal', 'small-caps', 'normal', '1.2em', 'normal', ['Ahem']), - - # - 'bold 1.2em Ahem': ('normal', 'normal', 'bold', '1.2em', 'normal', ['Ahem']), - } - CASE_2 = { - # / - '1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - '1.2em/3 Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', '3', - ['Ahem', 'White Space']), - '1.2em/3 Ahem, "White Space", serif': ('normal', 'normal', 'normal', '1.2em', '3', - ['Ahem', 'White Space', 'serif']), - - # - '1.2em Ahem': ('normal', 'normal', 'normal', '1.2em', 'normal', ['Ahem']), - '1.2em Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', 'normal', - ['Ahem', 'White Space']), - '1.2em Ahem, "White Space", serif': ('normal', 'normal', 'normal', '1.2em', 'normal', - ['Ahem', 'White Space', 'serif']), - } - CASE_1 = { - # | inherit - 'caption': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'icon': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'menu': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'message-box': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'small-caption': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'status-bar': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - 'inherit': ('normal', 'normal', 'normal', 'medium', 'normal', ['initial']), - } - CASE_EXTRAS = { - # Spaces in family - 'normal normal normal 1.2em/3 Ahem, "White Space"': ('normal', 'normal', 'normal', '1.2em', '3', - ['Ahem', 'White Space']), - "normal normal normal 1.2em/3 Ahem, 'White Space'": ('normal', 'normal', 'normal', '1.2em', '3', - ['Ahem', 'White Space']), - # Extra spaces - " normal normal normal 1.2em/3 Ahem, ' White Space ' ": ('normal', 'normal', 'normal', '1.2em', '3', - ['Ahem', 'White Space']), - } - CASE_5_INVALID = set([ - # / - ' small-caps bold 1.2em/3 Ahem', - ' small-caps bold 1.2em/3 Ahem', - ' normal bold 1.2em/3 Ahem', - ' small-caps normal 1.2em/3 Ahem', - ' small-caps normal 1.2em/3 Ahem', - ' normal normal 1.2em/3 Ahem', - ' normal bold 1.2em/3 Ahem', - - 'oblique bold 1.2em/3 Ahem', - 'normal bold 1.2em/3 Ahem', - 'oblique 1.2em/3 Ahem', - 'oblique normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - 'oblique normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - - 'oblique small-caps 1.2em/3 Ahem', - 'normal small-caps 1.2em/3 Ahem', - 'oblique normal 1.2em/3 Ahem', - 'oblique small-caps 1.2em/3 Ahem', - 'normal small-caps 1.2em/3 Ahem', - 'oblique normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - - 'oblique small-caps bold /3 Ahem', - 'normal small-caps bold /3 Ahem', - 'oblique normal bold /3 Ahem', - 'oblique small-caps normal /3 Ahem', - 'normal small-caps normal /3 Ahem', - 'oblique normal normal /3 Ahem', - 'normal normal bold /3 Ahem', - - 'oblique small-caps bold 1.2em/3 ', - 'normal small-caps bold 1.2em/3 ', - 'oblique normal bold 1.2em/3 ', - 'oblique small-caps normal 1.2em/3 ', - 'normal small-caps normal 1.2em/3 ', - 'oblique normal normal 1.2em/3 ', - - # / - ' oblique small-caps 1.2em/3 Ahem', - ' oblique small-caps 1.2em/3 Ahem', - ' normal small-caps 1.2em/3 Ahem', - ' oblique normal 1.2em/3 Ahem', - ' oblique normal 1.2em/3 Ahem', - ' normal normal 1.2em/3 Ahem', - ' normal small-caps 1.2em/3 Ahem', - - 'bold small-caps 1.2em/3 Ahem', - 'normal small-caps 1.2em/3 Ahem', - 'bold small-caps 1.2em/3 Ahem', - 'bold normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - 'bold normal 1.2em/3 Ahem', - 'normal small-caps 1.2em/3 Ahem', - - 'bold oblique 1.2em/3 Ahem', - 'normal oblique 1.2em/3 Ahem', - 'bold normal 1.2em/3 Ahem', - 'bold oblique 1.2em/3 Ahem', - 'normal oblique 1.2em/3 Ahem', - 'bold normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - - 'bold oblique small-caps /3 Ahem', - 'normal oblique small-caps /3 Ahem', - 'bold normal small-caps /3 Ahem', - 'bold oblique normal /3 Ahem', - 'normal oblique normal /3 Ahem', - 'bold normal normal /3 Ahem', - 'normal normal small-caps /3 Ahem', - - 'bold oblique small-caps 1.2em/ Ahem', - 'normal oblique small-caps 1.2em/ Ahem', - 'bold normal small-caps 1.2em/ Ahem', - 'bold oblique normal 1.2em/ Ahem', - 'normal oblique normal 1.2em/ Ahem', - 'bold normal normal 1.2em/ Ahem', - 'normal normal small-caps 1.2em/ Ahem', - - 'bold oblique small-caps 1.2em/3 ', - 'normal oblique small-caps 1.2em/3 ', - 'bold normal small-caps 1.2em/3 ', - 'bold oblique normal 1.2em/3 ', - 'normal oblique normal 1.2em/3 ', - 'bold normal normal 1.2em/3 ', - 'normal normal small-caps 1.2em/3 ', - - # / - ' bold oblique 1.2em/3 Ahem', - ' bold oblique 1.2em/3 Ahem', - ' normal oblique 1.2em/3 Ahem', - ' bold normal 1.2em/3 Ahem', - ' bold normal 1.2em/3 Ahem', - ' normal normal 1.2em/3 Ahem', - ' normal oblique 1.2em/3 Ahem', - - 'small-caps oblique 1.2em/3 Ahem', - 'normal oblique 1.2em/3 Ahem', - 'small-caps oblique 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - 'normal oblique 1.2em/3 Ahem', - - 'small-caps bold 1.2em/3 Ahem', - 'normal bold 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - 'small-caps bold 1.2em/3 Ahem', - 'normal bold 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - 'normal normal 1.2em/3 Ahem', - - 'small-caps bold oblique em/3 Ahem', - 'normal bold oblique /3 Ahem', - 'small-caps normal oblique /3 Ahem', - 'small-caps bold normal /3 Ahem', - 'normal bold normal /3 Ahem', - 'small-caps normal normal /3 Ahem', - 'normal normal oblique /3 Ahem', - - 'small-caps bold oblique /3 Ahem', - 'normal bold oblique /3 Ahem', - 'small-caps normal oblique /3 Ahem', - 'small-caps bold normal /3 Ahem', - 'normal bold normal /3 Ahem', - 'small-caps normal normal /3 Ahem', - 'normal normal oblique /3 Ahem', - - 'small-caps bold oblique 1.2em/ Ahem', - 'normal bold oblique 1.2em/ Ahem', - 'small-caps normal oblique 1.2em/ Ahem', - 'small-caps bold normal 1.2em/ Ahem', - 'normal bold normal 1.2em/ Ahem', - 'small-caps normal normal 1.2em/ Ahem', - 'normal normal oblique 1.2em/ Ahem', - - 'small-caps bold oblique 1.2em/3 ', - 'normal bold oblique 1.2em/3 ', - 'small-caps normal oblique 1.2em/3 ', - 'small-caps bold normal 1.2em/3 ', - 'normal bold normal 1.2em/3 ', - 'small-caps normal normal 1.2em/3 ', - 'normal normal oblique 1.2em/3 ', - - # / - ' oblique bold 1.2em/3 Ahem', - ' oblique bold 1.2em/3 Ahem', - ' normal bold 1.2em/3 Ahem', - ' oblique normal 1.2em/3 Ahem', - - 'small-caps bold 1.2em/3 Ahem', - 'normal bold 1.2em/3 Ahem', - 'small-caps bold 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - - 'small-caps oblique 1.2em/3 Ahem', - 'normal oblique 1.2em/3 Ahem', - 'small-caps normal 1.2em/3 Ahem', - 'small-caps oblique 1.2em/3 Ahem', - - 'small-caps oblique bold /3 Ahem', - 'normal oblique bold /3 Ahem', - 'small-caps normal bold /3 Ahem', - 'small-caps oblique normal /3 Ahem', - - 'small-caps oblique bold /3 Ahem', - 'normal oblique bold /3 Ahem', - 'small-caps normal bold /3 Ahem', - 'small-caps oblique normal /3 Ahem', - - 'small-caps oblique bold 1.2em/ Ahem', - 'normal oblique bold 1.2em/ Ahem', - 'small-caps normal bold 1.2em/ Ahem', - 'small-caps oblique normal 1.2em/ Ahem', - - 'small-caps oblique bold 1.2em/3 ', - 'normal oblique bold 1.2em/3 ', - 'small-caps normal bold 1.2em/3 ', - 'small-caps oblique normal 1.2em/3 ', - ]) - CASE_4_INVALID = set([ - # / - ' small-caps 1.2em/3 Ahem', - ' normal 1.2em/3 Ahem', - ' small-caps 1.2em/3 Ahem', - ' normal 1.2em/3 Ahem', - - 'oblique 1.2em/3 Ahem', - 'oblique 1.2em/3 Ahem', - 'normal 1.2em/3 Ahem', - 'normal 1.2em/3 Ahem', - - 'oblique small-caps /3 Ahem', - 'oblique normal /3 Ahem', - 'normal small-caps /3 Ahem', - 'normal normal /3 Ahem', - - 'oblique small-caps 1.2em/ Ahem', - 'oblique normal 1.2em/ Ahem', - 'normal small-caps 1.2em/ Ahem', - 'normal normal 1.2em/ Ahem', - 'oblique small-caps 1.2em/3 ', - 'oblique normal 1.2em/3 ', - 'normal small-caps 1.2em/3 ', - 'normal normal 1.2em/3 ', +def tuple_to_font_dict(tup, font_dict, remove_empty=False): + """Helper to convert a tuple to a font dict to check for valid outputs.""" + for idx, key in enumerate(('font_style', 'font_variant', 'font_weight', + 'font_size', 'line_height', 'font_family')): + value = tup[idx] - # / - ' bold 1.2em/3 Ahem', - ' bold 1.2em/3 Ahem', + if remove_empty: + if value is not EMPTY: + font_dict[key] = value + else: + font_dict[key] = value - 'oblique 1.2em/3 Ahem', - 'normal 1.2em/3 Ahem', + if key == 'font_family': + font_dict[key] = FontFamily(value) - 'oblique bold /3 Ahem', - 'normal bold /3 Ahem', + return font_dict - 'oblique bold 1.2em/ Ahem', - 'normal bold 1.2em/ Ahem', - 'oblique bold 1.2em/3 ', - 'normal bold 1.2em/3 ', - - # / - ' oblique 1.2em/3 Ahem', - ' normal 1.2em/3 Ahem', - ' oblique 1.2em/3 Ahem', - - 'bold 1.2em/3 Ahem', - 'bold 1.2em/3 Ahem', - 'normal 1.2em/3 Ahem', - - 'bold oblique /3 Ahem', - 'bold normal /3 Ahem', - 'normal oblique /3 Ahem', - - 'bold oblique 1.2em/ Ahem', - 'bold normal 1.2em/ Ahem', - 'normal oblique 1.2em/ Ahem', +# Test helpers +def construct_font(font_dict, order=0): + """Construct font property string from a dictionary of font properties.""" + font_dict_copy = font_dict.copy() + for key in font_dict: + val = font_dict[key] + if val == EMPTY: + val = '' - 'bold oblique 1.2em/3 ', - 'bold normal 1.2em/3 ', - 'normal oblique 1.2em/3 ', + if key == 'line_height' and val != '': + val = '/' + val - # / - ' small-caps 1.2em/3 Ahem', + font_dict_copy[key] = val - 'bold 1.2em/3 Ahem', + font_dict_copy['font_family'] = FontFamily(font_dict_copy['font_family']) - 'bold small-caps /3 Ahem', + strings = { + # Valid default order + 0: '{font_style} {font_variant} {font_weight} {font_size}{line_height} {font_family}', - 'bold small-caps 1.2em/ Ahem', + # Valid non default order + 1: '{font_style} {font_weight} {font_variant} {font_size}{line_height} {font_family}', + 2: '{font_weight} {font_variant} {font_style} {font_size}{line_height} {font_family}', + 3: '{font_weight} {font_style} {font_variant} {font_size}{line_height} {font_family}', + 4: '{font_variant} {font_weight} {font_style} {font_size}{line_height} {font_family}', + 5: '{font_variant} {font_style} {font_weight} {font_size}{line_height} {font_family}', - 'bold small-caps 1.2em/3 ', - ]) - CASE_3_INVALID = set([ - # / - ' 1.2em/3 Ahem', - - 'oblique /3 Ahem', - - 'oblique 1.2em/ Ahem', - - 'oblique 1.2em/3 ', - - # / - ' 1.2em/3 Ahem', - - 'small-caps /3 Ahem', - - 'small-caps 1.2em/ Ahem', - - 'small-caps 1.2em/3 ', - - # / - ' 1.2em/3 Ahem', - - 'bold /3 Ahem', - - 'bold 1.2em/ Ahem', - - 'bold 1.2em/3 ', - - # - ' 1.2em Ahem', - - 'oblique Ahem', - - 'oblique 1.2em ', - - # - ' 1.2em Ahem', - - 'small-caps Ahem', - - 'small-caps 1.2em ', - - # - ' 1.2em Ahem', - - 'bold Ahem', - - 'bold 1.2em ', - ]) - CASE_2_INVALID = set([ - # / - '/3 Ahem', - '1.2em/ Ahem, "White Space"', - '1.2em/3 , "White Space", serif', - '1.2em/3 Ahem, "", serif', - '1.2em/3 Ahem, , serif', - '1.2em/3 Ahem, "White Space", ', - - # - ' Ahem', - '1.2em , "White Space"', - '1.2em Ahem, "", serif', - '1.2em Ahem, , serif', - '1.2em Ahem, "White Space", ', - ]) - CASE_1_INVALID = set([ - # | inherit - 'Ahem', - '', - '20', - 20, - ]) - CASE_EXTRAS_INVALID = set([ - # Space between font-size and line-height - 'small-caps oblique normal 1.2em /3 Ahem', - 'small-caps oblique normal 1.2em/ 3 Ahem', - 'small-caps oblique normal 1.2em / 3 Ahem', - - # Too many parts - 'normal normal normal normal 12px/12px serif', - 'normal normal normal normal normal 12px/12px serif', - - # No quotes with spaces - 'small-caps oblique normal 1.2em/3 Ahem, White Space', - - # No commas - 'small-caps oblique normal 1.2em/3 Ahem "White Space"', - - # Incorrect order - '1.2em/3 small-caps oblique bold Ahem', - '1.2em/3 oblique bold small-caps Ahem', - '1.2em/3 bold small-caps oblique Ahem', - '1.2em/3 small-caps bold oblique Ahem', - - '1.2em small-caps oblique bold Ahem', - '1.2em oblique bold small-caps Ahem', - '1.2em bold small-caps oblique Ahem', - '1.2em small-caps bold oblique Ahem', - - 'small-caps 1.2em/3 oblique bold Ahem', - 'small-caps 1.2em/3 bold oblique Ahem', - 'bold 1.2em/3 small-caps oblique Ahem', - 'bold 1.2em/3 oblique small-caps Ahem', - 'oblique 1.2em/3 small-caps bold Ahem', - 'oblique 1.2em/3 bold small-caps Ahem', - - 'small-caps 1.2em oblique bold Ahem', - 'small-caps 1.2em bold oblique Ahem', - 'bold 1.2em small-caps oblique Ahem', - 'bold 1.2em oblique small-caps Ahem', - 'oblique 1.2em small-caps bold Ahem', - 'oblique 1.2em bold small-caps Ahem', - - 'small-caps bold 1.2em/3 oblique Ahem', - 'bold small-caps 1.2em/3 oblique Ahem', - 'oblique small-caps 1.2em/3 bold Ahem', - 'small-caps oblique 1.2em/3 bold Ahem', - 'oblique bold 1.2em/3 small-caps Ahem', - 'bold oblique 1.2em/3 small-caps Ahem', - - 'normal normal 1.2em/3 normal Ahem', - 'normal 1.2em/3 normal normal Ahem', - 'normal 1.2em/3 normal Ahem', - '1.2em/3 normal Ahem', - '1.2em/3 normal normal Ahem', - '1.2em/3 normal normal normal Ahem', - ]) - - # Font construction test cases - CASE_CONSTRUCT = { - # / - 'oblique small-caps bold 1.2em/3 Ahem': ('oblique', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'normal small-caps bold 1.2em/3 Ahem': ('normal', 'small-caps', 'bold', '1.2em', '3', ['Ahem']), - 'oblique normal bold 1.2em/3 Ahem': ('oblique', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'oblique small-caps normal 1.2em/3 Ahem': ('oblique', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'normal small-caps normal 1.2em/3 Ahem': ('normal', 'small-caps', 'normal', '1.2em', '3', ['Ahem']), - 'oblique normal normal 1.2em/3 Ahem': ('oblique', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'normal normal bold 1.2em/3 Ahem': ('normal', 'normal', 'bold', '1.2em', '3', ['Ahem']), - 'normal normal normal 1.2em/3 Ahem': ('normal', 'normal', 'normal', '1.2em', '3', ['Ahem']), - 'normal normal 900 1.2em/3 Ahem': ('normal', 'normal', '900', '1.2em', '3', ['Ahem']), + # Invalid order + 10: '{font_size}{line_height} {font_style} {font_weight} {font_variant} {font_family}', + 11: '{font_weight} {font_size}{line_height} {font_variant} {font_style} {font_family}', + 12: '{font_weight} {font_style} {font_size}{line_height} {font_variant} {font_family}', + 13: '{font_style} {font_weight} {font_size}{line_height} {font_variant} {font_family}', + 14: '{font_weight} {font_variant} {font_size}{line_height} {font_style} {font_family}', + 15: '{font_variant} {font_weight} {font_size}{line_height} {font_style} {font_family}', + 16: '{font_style} {font_variant} {font_size}{line_height} {font_weight} {font_family}', + 17: '{font_variant} {font_style} {font_size}{line_height} {font_weight} {font_family}', + 18: '{font_variant} {font_style} {font_family} {font_size}{line_height} {font_weight}', + 19: '{font_family} {font_variant} {font_style} {font_size}{line_height} {font_weight}', } - CASE_CONSTRUCT_FAMILY = { - 'Ahem': ['Ahem'], - 'Ahem, "White Space"': ['Ahem', 'White Space'], - 'Ahem, "White Space", serif': ['Ahem', 'White Space', 'serif'], - } - - @staticmethod - def tuple_to_font_dict(tup): - """Helper to convert a tuple to a font dict to check for valid outputs.""" - font_dict = {} - for idx, key in enumerate(['font_style', 'font_variant', 'font_weight', - 'font_size', 'line_height', 'font_family']): - font_dict[key] = tup[idx] - - return font_dict - - def test_parse_font_shorthand(self): - for cases in [self.CASE_5, self.CASE_4, self.CASE_3, self.CASE_2, self.CASE_1, self.CASE_EXTRAS]: - for case in sorted(cases): - expected_output = self.tuple_to_font_dict(cases[case]) - font = parse_font(case) - self.assertEqual(font, expected_output) - - def test_parse_font_shorthand_invalid(self): - for cases in [self.CASE_5_INVALID, self.CASE_4_INVALID, self.CASE_3_INVALID, self.CASE_2_INVALID, - self.CASE_1_INVALID, self.CASE_EXTRAS_INVALID]: - for case in cases: - with self.assertRaises(ValueError): - parse_font(case) - - def test_construct_font_shorthand(self): - for expected_output, tup in sorted(self.CASE_CONSTRUCT.items()): - case = self.tuple_to_font_dict(tup) - font = construct_font(case) - self.assertEqual(font, expected_output) - - def test_construct_font_family(self): - for expected_output, case in sorted(self.CASE_CONSTRUCT_FAMILY.items()): - font_family = construct_font_family(case) - self.assertEqual(font_family, expected_output) + string = ' '.join(str(strings[order].format(**font_dict_copy)).strip().split()) + return string + + +def helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family): + font_with_empty_values = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=False) + + font_properties = set() + for order in range(6): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties.add(font_property) + with pytest.raises(Exception): + print(font_with_empty_values) + print('Font: ' + font_property) + parse_font(font_property) + + +# Tests +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_2_to_5_parts(font_style, font_variant, font_weight, font_size, line_height, + font_family): + font_with_empty_values = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=False) + + expected_output = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=True) + + # Valid + font_properties = set() + for order in range(6): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties.add(font_property) + font = parse_font(font_property) + print('\nfont: ', font_property) + print('parsed: ', sorted(font.items())) + print('expected: ', sorted(expected_output.items())) + assert font == expected_output + + # Invalid + font_properties_invalid = set() + for order in range(10, 20): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties_invalid.add(font_property) + print('\nfont: ', font_property) + with pytest.raises(Exception): + font = parse_font(font_property) + + +@pytest.mark.parametrize('font_style', [INVALID]) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_1(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [INVALID]) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_2(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [INVALID]) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_3(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', [INVALID]) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_4(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [INVALID]) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_5(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [[INVALID], ['Ahem', INVALID]]) +def test_parse_font_shorthand_invalid_6(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_property_string', [ + INVALID, + # Space between font-size and line-height + 'small-caps oblique normal 1.2em /3 Ahem', + 'small-caps oblique normal 1.2em/ 3 Ahem', + 'small-caps oblique normal 1.2em / 3 Ahem', + + # Too many parts + 'normal normal normal normal 12px/12px serif', + 'normal normal normal normal normal 12px/12px serif', + + # No quotes with spaces + 'small-caps oblique normal 1.2em/3 Ahem, White Space', + + # No commas + 'small-caps oblique normal 1.2em/3 Ahem "White Space"', + + # Repeated options + 'bold 500 oblique 9px/2 Ahem', + 'bigger smaller Ahem', + + # | inherit + 'Ahem', + '', + '20', + 20, +]) +def test_parse_font_shorthand_invalid_extras(font_property_string): + with pytest.raises(Exception): + print('Font: ' + font_property_string) + parse_font(font_property_string) diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py new file mode 100644 index 000000000..4fffdcfae --- /dev/null +++ b/tests/test_wrappers.py @@ -0,0 +1,136 @@ +from unittest import TestCase + +from colosseum.wrappers import FontFamily, FontShorthand, ImmutableList, Shorthand + + +class ImmutableListTests(TestCase): + + def test_immutablelist(self): + # Check initial + ilist = ImmutableList() + self.assertEqual(str(ilist), 'ImmutableList()') + self.assertEqual(repr(ilist), 'ImmutableList()') + self.assertEqual(len(ilist), 0) + + # Check value + ilist = ImmutableList([1]) + self.assertEqual(str(ilist), "ImmutableList([1])") + self.assertEqual(repr(ilist), "ImmutableList([1])") + self.assertEqual(len(ilist), 1) + + # Check values + ilist = ImmutableList(['Ahem', 'White Space', 'serif']) + self.assertEqual(str(ilist), "ImmutableList(['Ahem', 'White Space', 'serif'])") + self.assertEqual(repr(ilist), "ImmutableList(['Ahem', 'White Space', 'serif'])") + self.assertEqual(len(ilist), 3) + + # Check get item + self.assertEqual(ilist[0], 'Ahem') + self.assertEqual(ilist[-1], 'serif') + + # Check immutable + with self.assertRaises(TypeError): + ilist[0] = 'initial' + + # Check equality + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + ilist3 = ImmutableList([2, 'Ahem']) + self.assertEqual(ilist1, ilist2) + self.assertNotEqual(ilist1, ilist3) + + # Check hash + self.assertEqual(hash(ilist1), hash(ilist2)) + + # Check copy + self.assertNotEqual(id(ilist1), id(ilist2)) + self.assertNotEqual(id(ilist1), id(ilist1.copy())) + self.assertNotEqual(id(ilist2), id(ilist1.copy())) + self.assertEqual(hash(ilist2), hash(ilist1.copy())) + self.assertEqual(ilist1, ilist1.copy()) + + +class FontFamilyTests(TestCase): + + def test_fontfamily(self): + # Check initial + font = FontFamily() + self.assertEqual(str(font), '') + self.assertEqual(repr(font), 'FontFamily()') + self.assertEqual(len(font), 0) + + # Check value + font = FontFamily(['Ahem']) + self.assertEqual(str(font), 'Ahem') + self.assertEqual(repr(font), "FontFamily(['Ahem'])") + self.assertEqual(len(font), 1) + + # Check values + font = FontFamily(['Ahem', 'White Space', 'serif']) + self.assertEqual(str(font), 'Ahem, "White Space", serif') + self.assertEqual(repr(font), "FontFamily(['Ahem', 'White Space', 'serif'])") + self.assertEqual(len(font), 3) + + # Check get item + self.assertEqual(font[0], 'Ahem') + self.assertEqual(font[-1], 'serif') + + # Check immutable + with self.assertRaises(TypeError): + font[0] = 'initial' + + # Check equality + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) + font3 = FontFamily(['serif', 'Ahem']) + self.assertEqual(font1, font2) + self.assertNotEqual(font1, font3) + + # Check hash + self.assertEqual(hash(font1), hash(font2)) + + # Check copy + self.assertNotEqual(id(font1), id(font2)) + self.assertNotEqual(id(font1), id(font1.copy())) + self.assertNotEqual(id(font2), id(font2.copy())) + self.assertEqual(font1, font1.copy()) + + +class ShorthandTests(TestCase): + + def test_shorthand(self): + shorthand = Shorthand() + self.assertEqual(str(shorthand), 'Shorthand()') + self.assertEqual(repr(shorthand), ("Shorthand()")) + + +class FontShorthandTests(TestCase): + + def test_font_shorthand(self): + # Check initial + font = FontShorthand() + self.assertEqual(str(font), 'normal normal normal medium/normal initial') + self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + + font = FontShorthand(font_weight='bold') + self.assertEqual(str(font), 'normal normal bold medium/normal initial') + self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='normal', font_weight='bold', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + + font = FontShorthand(font_variant='small-caps') + self.assertEqual(str(font), 'normal small-caps normal medium/normal initial') + self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='small-caps', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + + font = FontShorthand(font_style='oblique') + self.assertEqual(str(font), 'oblique normal normal medium/normal initial') + self.assertEqual(repr(font), ("FontShorthand(font_style='oblique', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + # Check invalid key + with self.assertRaises(KeyError): + font['invalid-key'] = 2 + + # Copy + self.assertEqual(font, font.copy()) + self.assertNotEqual(id(font), id(font.copy())) diff --git a/tests/utils.py b/tests/utils.py index f1228a16d..d4bfa5e15 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -156,8 +156,8 @@ def output_layout(layout, depth=1): return (' ' * depth + "* '{text}'\n".format(text=layout['text'].strip())) -def copy_fonts(system=False): - """Copy needed files for running tests.""" +def install_fonts(system=False): + """Install needed files for running tests.""" fonts_folder = FontDatabase.fonts_path(system=system) if not os.path.isdir(fonts_folder): @@ -171,9 +171,9 @@ def copy_fonts(system=False): if not os.path.isfile(font_file_path): shutil.copyfile(font_file_data_path, font_file_path) - # Register font if os.name == 'nt': + # Register font import winreg # noqa base_key = winreg.HKEY_LOCAL_MACHINE if system else winreg.HKEY_CURRENT_USER key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" @@ -209,10 +209,10 @@ class ColosseumTestCase(TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self._FONTS_ACTIVE is False: - self.copy_fonts() + self.install_fonts() - def copy_fonts(self): - copy_fonts() + def install_fonts(self): + install_fonts() try: FontDatabase.validate_font_family('Ahem') @@ -475,4 +475,4 @@ def test_method(self): if sys.platform.startswith('linux'): system = False print('Copying test fonts to "{path}"...'.format(path=FontDatabase.fonts_path(system=system))) - copy_fonts(system=system) + install_fonts(system=system) From e4cd59ce16918a3299aa5c3d6c8a5ba68a9189f4 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 15 Jan 2020 21:51:31 -0500 Subject: [PATCH 27/32] Fix code style --- tests/test_declaration.py | 4 ++-- tests/test_parser.py | 3 +-- tests/test_wrappers.py | 28 ++++++++++++++++++++-------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index ebe441e1d..8cf90a795 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -11,7 +11,7 @@ from colosseum.validators import ( is_color, is_integer, is_length, is_number, is_percentage, ) -from colosseum.wrappers import FontFamily, FontShorthand, ImmutableList +from colosseum.wrappers import FontFamily, FontShorthand from .utils import ColosseumTestCase, TestNode @@ -800,7 +800,7 @@ def test_font_family_property(self): # Check type self.assertTrue(isinstance(node.style.font_family, FontFamily)) - + # Check Initial values self.assertEqual(node.style.font_family, FontFamily([INITIAL])) diff --git a/tests/test_parser.py b/tests/test_parser.py index fcb4a2374..a55f5218d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -6,11 +6,10 @@ from colosseum.colors import hsl, rgb from colosseum.constants import INITIAL_FONT_VALUES from colosseum.parser import parse_font -from colosseum.wrappers import FontFamily, FontShorthand +from colosseum.wrappers import FontFamily from colosseum.units import ( ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw, ) -from .utils import ColosseumTestCase class ParseUnitTests(TestCase): diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 4fffdcfae..b3bb475ed 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -110,23 +110,35 @@ def test_font_shorthand(self): # Check initial font = FontShorthand() self.assertEqual(str(font), 'normal normal normal medium/normal initial') - self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='normal', font_weight='normal', " - "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) font = FontShorthand(font_weight='bold') self.assertEqual(str(font), 'normal normal bold medium/normal initial') - self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='normal', font_weight='bold', " - "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='normal', font_weight='bold', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) font = FontShorthand(font_variant='small-caps') self.assertEqual(str(font), 'normal small-caps normal medium/normal initial') - self.assertEqual(repr(font), ("FontShorthand(font_style='normal', font_variant='small-caps', font_weight='normal', " - "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='small-caps', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) font = FontShorthand(font_style='oblique') self.assertEqual(str(font), 'oblique normal normal medium/normal initial') - self.assertEqual(repr(font), ("FontShorthand(font_style='oblique', font_variant='normal', font_weight='normal', " - "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))")) + self.assertEqual( + repr(font), + ("FontShorthand(font_style='oblique', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) # Check invalid key with self.assertRaises(KeyError): font['invalid-key'] = 2 From dc1710f6697626e3a00c86c01278ede1d0c09500 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Wed, 15 Jan 2020 21:56:23 -0500 Subject: [PATCH 28/32] Fix error on GTK test --- colosseum/fonts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index db9d9dc9c..ffa420b6a 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -95,7 +95,7 @@ def get_font(self, value): if _GTK_WINDOW is None: _GTK_WINDOW = Window() - return _GTK_WINDOW.check_system_font(value) + return _GTK_WINDOW.get_font(value) def _check_font_family_win(value): From c2bd8cddd17fce640092c987ab7005bcbc1a6e9f Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Sun, 19 Jan 2020 22:49:27 -0500 Subject: [PATCH 29/32] Break tests, add pytest-xdist, clean up code --- .github/workflows/ci.yml | 16 ++-- colosseum/constants.py | 5 ++ colosseum/declaration.py | 71 ++++++---------- colosseum/fonts.py | 169 ++++++++++++++++++++------------------ colosseum/parser.py | 4 +- colosseum/validators.py | 42 +++++----- colosseum/wrappers.py | 10 ++- tests/test_declaration.py | 11 +-- tests/test_validators.py | 17 ++-- tests/test_wrappers.py | 54 +++++++++++- 10 files changed, 220 insertions(+), 179 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5de868a1c..16d790d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,14 +37,14 @@ jobs: - name: Install dependencies run: | sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config - pip install --upgrade pip setuptools pytest pytest-tldr + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . - name: Install fonts run: | xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | - xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ -n auto python-versions: name: Python compatibility test (Linux) @@ -63,14 +63,14 @@ jobs: - name: Install dependencies run: | sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config - pip install --upgrade pip setuptools pytest pytest-tldr + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . - name: Install fonts run: | xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | - xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ -n auto windows: name: Windows tests @@ -84,14 +84,14 @@ jobs: python-version: 3.5 - name: Install dependencies run: | - pip install --upgrade pip setuptools pytest pytest-tldr + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . - name: Install fonts run: | python tests/utils.py - name: Test run: | - pytest tests/ + pytest tests/ -n auto macOS: name: macOS tests @@ -105,11 +105,11 @@ jobs: python-version: 3.5 - name: Install dependencies run: | - pip install --upgrade pip setuptools pytest pytest-tldr + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . - name: Install fonts run: | python tests/utils.py - name: Test run: | - pytest tests/ + pytest tests/ -n auto diff --git a/colosseum/constants.py b/colosseum/constants.py index dbb0595b5..cff4c7760 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -52,6 +52,11 @@ def __str__(self): HTML4 = 'html4' HTML5 = 'html5' +###################################################################### +# Other constants +###################################################################### +EMPTY = '' + ###################################################################### # Common constants ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 81b26cbf0..9ca349311 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -15,7 +15,7 @@ MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, MIN_SIZE_CHOICES, NORMAL, NOWRAP, ORDER_CHOICES, PADDING_CHOICES, POSITION_CHOICES, ROW, SIZE_CHOICES, STATIC, STRETCH, TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, - VISIBLE, Z_INDEX_CHOICES, default, + VISIBLE, Z_INDEX_CHOICES, default, INITIAL_FONT_VALUES, EMPTY, ) from .exceptions import ValidationError from .parser import parse_font @@ -24,24 +24,23 @@ _CSS_PROPERTIES = set() -def validated_font_property(name, initial): +def validated_shorthand_property(name, initial, parser, storage_class): """Define the shorthand CSS font property.""" - assert isinstance(initial, FontShorthand) - initial = initial.copy() + initial = storage_class(**initial) def getter(self): - font = initial.copy() - for property_name in font.keys(): - font[property_name] = getattr(self, property_name) - return font + shorthand = initial.copy() + for property_name in shorthand: + shorthand[property_name] = getattr(self, property_name) + return shorthand def setter(self, value): try: - font = FontShorthand(**parse_font(value)) + shorthand = storage_class(**parser(value)) except ValidationError: raise ValueError("Invalid value '%s' for CSS property '%s'!" % (value, name)) - for property_name, property_value in font.items(): + for property_name, property_value in shorthand.items(): setattr(self, property_name, property_value) setattr(self, '_%s' % name, value) @@ -67,8 +66,9 @@ def deleter(self): return property(getter, setter, deleter) -def validated_list_property(name, choices, initial, separator=',', add_quotes=False): +def validated_list_property(name, choices, initial, storage_class, separator=',', add_quotes=False): """Define a property holding a list values.""" + initial = storage_class(choices.validate(initial)) def _add_quotes(values): """Add quotes to items that contain spaces.""" @@ -88,28 +88,21 @@ def getter(self): def setter(self, value): if isinstance(value, collections.Sequence) and not isinstance(value, str): if add_quotes: - value = _add_quotes(value) - value = separator.join(value) + values = _add_quotes(value) elif isinstance(value, str): - pass + values = value.split(separator) else: raise ValueError('Invalid value for CSS property!') try: - # The validator must validate the string version (all values at once) - # because the order of parameters might be important. - values = choices.validate(value) - - # The validator must return a list - assert isinstance(values, list) + values = storage_class(choices.validate(values)) except ValueError: raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( value, name, choices )) - if values != getattr(self, '_%s' % name, list(initial)): - # We use the initial type to make an instance with the set values - setattr(self, '_%s' % name, initial.__class__(values)) + if values != getattr(self, '_%s' % name, initial): + setattr(self, '_%s' % name, values) self.dirty = True def deleter(self): @@ -382,8 +375,8 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, initial=FontFamily([INITIAL]), - add_quotes=True) + font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, storage_class=FontFamily, + initial=[INITIAL], add_quotes=True) # 15.4 Font Styling font_style = validated_property('font_style', choices=FONT_STYLE_CHOICES, initial=NORMAL) @@ -398,7 +391,8 @@ def __init__(self, **style): font_size = validated_property('font_size', choices=FONT_SIZE_CHOICES, initial=MEDIUM) # 15.8 Shorthand font property - font = validated_font_property('font', initial=FontShorthand()) + font = validated_shorthand_property('font', initial=INITIAL_FONT_VALUES, parser=parse_font, + storage_class=FontShorthand) # 16. Text ########################################################### # 16.1 Indentation @@ -611,27 +605,12 @@ def keys(self): def __str__(self): non_default = [] - # Other shorthand properties have to be included here - shorthand_properties = ['border', 'border_top', 'border_right', 'border_bottom', 'border_left', 'font'] - for name in _CSS_PROPERTIES: - # Shorthand values are constructed on demand since they depend on - # other properties, we have to use getattr(self, name) instead of - # getattr(self, '_%s' % name) - if name in shorthand_properties: - try: - if getattr(self, '_%s' % name, None): - non_default.append((name, getattr(self, name))) - except AttributeError: - pass - else: - try: - non_default.append(( - name.replace('_', '-'), - getattr(self, '_%s' % name) - )) - except AttributeError: - pass + if getattr(self, '_%s' % name, EMPTY) != EMPTY: + non_default.append(( + name.replace('_', '-'), + getattr(self, name) + )) return "; ".join( "%s: %s" % (name, value) diff --git a/colosseum/fonts.py b/colosseum/fonts.py index ffa420b6a..9abd6df5b 100644 --- a/colosseum/fonts.py +++ b/colosseum/fonts.py @@ -5,11 +5,8 @@ from .exceptions import ValidationError -# Constants -_GTK_WINDOW = None - -class FontDatabase: +class _FontDatabaseBase: """ Provide information about the fonts available in the underlying system. """ @@ -25,7 +22,7 @@ def validate_font_family(cls, value): if value in cls._FONTS_CACHE: return value else: - font_exists = check_font_family(value) + font_exists = cls.check_font_family(value) if font_exists: # TODO: to be filled with a cross-platform font properties instance cls._FONTS_CACHE[value] = None @@ -33,100 +30,106 @@ def validate_font_family(cls, value): raise ValidationError('Font family "{value}" not found on system!'.format(value=value)) + @staticmethod + def check_font_family(value): + raise NotImplementedError() + @staticmethod def fonts_path(system=False): """Return the path for cross platform user fonts.""" - if sys.platform == 'win32': - import winreg - if system: - fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%windir%'), 'Fonts') - else: - fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%LocalAppData%'), - 'Microsoft', 'Windows', 'Fonts') - elif sys.platform == 'darwin': - if system: - fonts_dir = os.path.expanduser('/Library/Fonts') - else: - fonts_dir = os.path.expanduser('~/Library/Fonts') - elif sys.platform.startswith('linux'): - if system: - fonts_dir = os.path.expanduser('/usr/local/share/fonts') - else: - fonts_dir = os.path.expanduser('~/.local/share/fonts/') + raise NotImplementedError('System not supported!') + + +class _FontDatabaseMac(_FontDatabaseBase): + + @staticmethod + def check_font_family(value): + from ctypes import cdll, util + from rubicon.objc import ObjCClass + appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa + NSFont = ObjCClass('NSFont') + return bool(NSFont.fontWithName(value, size=0)) # size=0 returns defautl size + + @staticmethod + def fonts_path(system=False): + if system: + fonts_dir = os.path.expanduser('/Library/Fonts') else: - raise NotImplementedError('System not supported!') + fonts_dir = os.path.expanduser('~/Library/Fonts') return fonts_dir -def _check_font_family_mac(value): - """Get font by family name and size on mac.""" - from ctypes import cdll, util - from rubicon.objc import ObjCClass - appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa - NSFont = ObjCClass('NSFont') - return bool(NSFont.fontWithName(value, size=0)) # size=0 returns defautl size - +class _FontDatabaseWin(_FontDatabaseBase): -def _check_font_family_linux(value): - """List available font family names on linux.""" - import gi # noqa - gi.require_version("Gtk", "3.0") - gi.require_version("Pango", "1.0") - from gi.repository import Gtk # noqa - from gi.repository import Pango # noqa + @staticmethod + def check_font_family(value): + import winreg # noqa + + font_name = value + ' (TrueType)' # TODO: check other options + font = False + key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" + for base in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + with winreg.OpenKey(base, key_path, 0, winreg.KEY_READ) as reg_key: + try: + # Query if it exists + font = winreg.QueryValueEx(reg_key, font_name) + return True + except FileNotFoundError: + pass + + return font - class Window(Gtk.Window): - """Use Pango to get system fonts names.""" + @staticmethod + def fonts_path(system=False): + import winreg + if system: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%windir%'), 'Fonts') + else: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%LocalAppData%'), + 'Microsoft', 'Windows', 'Fonts') + return fonts_dir - def get_font(self, value): - """Get font from the system.""" - context = self.create_pango_context() - font = context.load_font(Pango.FontDescription(value)) - # Pango always loads something close to the requested so we need to check - # the actual loaded font is the requested one. - if font.describe().to_string().startswith(value): - return True # TODO: Wrap on a font cross platform wrapper +class _FontDatabaseLinux(_FontDatabaseBase): + _GTK_WINDOW = None - return False + @classmethod + def check_font_family(cls, value): + import gi # noqa + gi.require_version("Gtk", "3.0") + gi.require_version("Pango", "1.0") + from gi.repository import Gtk # noqa + from gi.repository import Pango # noqa - global _GTK_WINDOW # noqa - if _GTK_WINDOW is None: - _GTK_WINDOW = Window() + class Window(Gtk.Window): + """Use Pango to get system fonts names.""" - return _GTK_WINDOW.get_font(value) + def get_font(self, value): + """Get font from the system.""" + context = self.create_pango_context() + font = context.load_font(Pango.FontDescription(value)) + # Pango always loads something close to the requested so we need to check + # the actual loaded font is the requested one. + if font.describe().to_string().startswith(value): + return True # TODO: Wrap on a font cross platform wrapper -def _check_font_family_win(value): - """List available font family names on windows.""" - import winreg # noqa + return False - font_name = value + ' (TrueType)' # TODO: check other options - font = False - key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" - for base in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: - with winreg.OpenKey(base, key_path, 0, winreg.KEY_READ) as reg_key: - try: - # Query if it exists - font = winreg.QueryValueEx(reg_key, font_name) - return True - except FileNotFoundError: - pass + if cls._GTK_WINDOW is None: + cls._GTK_WINDOW = Window() - return font + return cls._GTK_WINDOW.get_font(value) + @staticmethod + def fonts_path(system=False): + if system: + fonts_dir = os.path.expanduser('/usr/local/share/fonts') + else: + fonts_dir = os.path.expanduser('~/.local/share/fonts/') -def check_font_family(value): - """List available font family names.""" - if sys.platform == 'darwin': - return _check_font_family_mac(value) - elif sys.platform.startswith('linux'): - return _check_font_family_linux(value) - elif sys.platform == 'win32': - return _check_font_family_win(value) - else: - raise NotImplementedError('Cannot check font existence on this system!') + return fonts_dir def get_system_font(keyword): @@ -138,3 +141,13 @@ def get_system_font(keyword): return INITIAL_FONT_VALUES.copy() return None + + +if sys.platform == 'win32': + FontDatabase = _FontDatabaseWin +elif sys.platform == 'darwin': + FontDatabase = _FontDatabaseMac +elif sys.platform.startswith('linux'): + FontDatabase = _FontDatabaseLinux +else: + raise ImportError('System not supported!') diff --git a/colosseum/parser.py b/colosseum/parser.py index 01055b46a..35167fb6c 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -257,8 +257,8 @@ def parse_font(string): # / raise ValueError('Font property shorthand contains too many parts!') - value = ' '.join(parts) - font_dict['font_family'] = FontFamily(FONT_FAMILY_CHOICES.validate(value)) + values = ' '.join(parts).split(',') + font_dict['font_family'] = FontFamily(FONT_FAMILY_CHOICES.validate(values)) full_font_dict = INITIAL_FONT_VALUES.copy() full_font_dict.update(font_dict) diff --git a/colosseum/validators.py b/colosseum/validators.py index 9c97fc0e9..cd5347454 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -1,3 +1,4 @@ +from collections import Sequence import ast import re @@ -112,30 +113,27 @@ def is_color(value): _CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') -def is_font_family(value): - """ - Validate that value is a valid font family. - - This validator returns a list. - """ +def is_font_family(values): + """Validate that values are a valid list of font families.""" from .constants import GENERIC_FAMILY_FONTS as generic_family FontDatabase = fonts.FontDatabase + assert isinstance(values, Sequence) and not isinstance(values, str) + # Remove extra outer spaces - font_value = ' '.join(value.strip().split()) - values = [v.strip() for v in font_value.split(',')] + values = [value.strip() for value in values] checked_values = [] - for val in values: + for value in values: # Remove extra inner spaces - val = val.replace('" ', '"') - val = val.replace(' "', '"') - val = val.replace("' ", "'") - val = val.replace(" '", "'") - if (val.startswith('"') and val.endswith('"') - or val.startswith("'") and val.endswith("'")): + value = value.replace('" ', '"') + value = value.replace(' "', '"') + value = value.replace("' ", "'") + value = value.replace(" '", "'") + if (value.startswith('"') and value.endswith('"') + or value.startswith("'") and value.endswith("'")): try: - no_quotes_val = ast.literal_eval(val) + no_quotes_val = ast.literal_eval(value) except ValueError: raise exceptions.ValidationError @@ -143,14 +141,14 @@ def is_font_family(value): raise exceptions.ValidationError('Font family "{font_value}"' ' not found on system!'.format(font_value=no_quotes_val)) checked_values.append(no_quotes_val) - elif val in generic_family: - checked_values.append(val) + elif value in generic_family: + checked_values.append(value) else: - error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=val) - if _CSS_IDENTIFIER_RE.match(val): - if not FontDatabase.validate_font_family(val): + error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=value) + if _CSS_IDENTIFIER_RE.match(value): + if not FontDatabase.validate_font_family(value): raise exceptions.ValidationError(error_msg) - checked_values.append(val) + checked_values.append(value) else: raise exceptions.ValidationError(error_msg) diff --git a/colosseum/wrappers.py b/colosseum/wrappers.py index 9956e9455..ab93238dd 100644 --- a/colosseum/wrappers.py +++ b/colosseum/wrappers.py @@ -2,12 +2,15 @@ class ImmutableList(Sequence): + """ + Immutable list to store list properties like outline and font family. + """ def __init__(self, iterable=()): self._data = tuple(iterable) def _get_error_message(self, err): - return str(err).replace('list', self.__class__.__name__, 1) + return str(err).replace('tuple', self.__class__.__name__, 1) def __eq__(self, other): return other.__class__ == self.__class__ and self._data == other._data @@ -43,7 +46,7 @@ def copy(self): class FontFamily(ImmutableList): - """List like wrapper to hold font families.""" + """Immutable list like wrapper to store font families.""" def __init__(self, iterable=()): try: @@ -118,6 +121,9 @@ def __len__(self): def __str__(self): return repr(self) + def __iter__(self): + return iter(self.VALID_KEYS) if self.VALID_KEYS else iter(self._map) + def keys(self): return self._map.keys() diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 8cf90a795..253397477 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -445,7 +445,6 @@ def test_list_property(self): node.layout.dirty = None # Check initial value - print(node.style.font_family) self.assertEqual(node.style.font_family, FontFamily(['initial'])) # Check valid values @@ -465,16 +464,12 @@ def test_list_property(self): node.style.font_family = ['"White Space"'] self.assertEqual(node.style.font_family, FontFamily(['White Space'])) - # Check the error message + # Check it raises try: node.style.font_family = ['123'] self.fail('Should raise ValueError') - except ValueError as v: - self.assertEqual( - str(v), - ("Invalid value '123' for CSS property 'font_family'; Valid values are: " - ", , inherit, initial") - ) + except ValueError: + pass def test_directional_property(self): node = TestNode(style=CSS()) diff --git a/tests/test_validators.py b/tests/test_validators.py index 2db73d8ab..bd0c190bc 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -44,13 +44,12 @@ def test_number(self): class FontTests(TestCase): def test_font_family_name_valid(self): - self.assertEqual(is_font_family('Ahem, serif'), ['Ahem', 'serif']) - self.assertEqual(is_font_family("Ahem,fantasy"), ["Ahem", 'fantasy']) - self.assertEqual(is_font_family(" Ahem , fantasy "), ["Ahem", 'fantasy']) - self.assertEqual(is_font_family("Ahem,'White Space'"), ["Ahem", 'White Space']) - self.assertEqual(is_font_family("Ahem, 'White Space'"), ["Ahem", 'White Space']) - self.assertEqual(is_font_family(" Ahem , ' White Space ' "), ["Ahem", 'White Space']) - self.assertEqual(is_font_family(" Ahem , \" White Space \" "), ["Ahem", 'White Space']) + self.assertEqual(is_font_family(['Ahem', 'serif']), ['Ahem', 'serif']) + self.assertEqual(is_font_family([" Ahem ", " fantasy "]), ["Ahem", 'fantasy']) + self.assertEqual(is_font_family(["Ahem", "'White Space'"]), ["Ahem", 'White Space']) + self.assertEqual(is_font_family(["Ahem", '"White Space"']), ["Ahem", 'White Space']) + self.assertEqual(is_font_family([" Ahem ", " ' White Space ' "]), ["Ahem", 'White Space']) + self.assertEqual(is_font_family([" Ahem ", ' \" White Space \" ']), ["Ahem", 'White Space']) def test_font_family_name_invalid(self): invalid_cases = [ @@ -61,8 +60,8 @@ def test_font_family_name_invalid(self): '#POUND, sans-serif', 'Hawaii 5-0, sans-serif', '123', - 'ThisIsDefintelyNotAFont' + 'ThisIsDefintelyNotAFontFamily' ] for case in invalid_cases: with self.assertRaises(ValidationError): - is_font_family(case) + is_font_family([case]) diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index b3bb475ed..3d59bbc84 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -5,13 +5,14 @@ class ImmutableListTests(TestCase): - def test_immutablelist(self): + def test_immutable_list_initial(self): # Check initial ilist = ImmutableList() self.assertEqual(str(ilist), 'ImmutableList()') self.assertEqual(repr(ilist), 'ImmutableList()') self.assertEqual(len(ilist), 0) + def test_immutable_list_creation(self): # Check value ilist = ImmutableList([1]) self.assertEqual(str(ilist), "ImmutableList([1])") @@ -24,14 +25,19 @@ def test_immutablelist(self): self.assertEqual(repr(ilist), "ImmutableList(['Ahem', 'White Space', 'serif'])") self.assertEqual(len(ilist), 3) + def test_immutable_list_get_item(self): # Check get item + ilist = ImmutableList(['Ahem', 'White Space', 'serif']) self.assertEqual(ilist[0], 'Ahem') self.assertEqual(ilist[-1], 'serif') + def test_immutable_list_set_item(self): # Check immutable + ilist = ImmutableList() with self.assertRaises(TypeError): ilist[0] = 'initial' + def test_immutable_list_equality(self): # Check equality ilist1 = ImmutableList(['Ahem', 2]) ilist2 = ImmutableList(['Ahem', 2]) @@ -39,26 +45,40 @@ def test_immutablelist(self): self.assertEqual(ilist1, ilist2) self.assertNotEqual(ilist1, ilist3) + def test_immutable_list_hash(self): # Check hash + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + self.assertEqual(hash(ilist1), hash(ilist2)) - # Check copy + def test_immutable_list_id(self): + # Check id + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) self.assertNotEqual(id(ilist1), id(ilist2)) self.assertNotEqual(id(ilist1), id(ilist1.copy())) self.assertNotEqual(id(ilist2), id(ilist1.copy())) + + def test_immutable_list_copy(self): + # Check copy + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + self.assertEqual(hash(ilist2), hash(ilist1.copy())) self.assertEqual(ilist1, ilist1.copy()) class FontFamilyTests(TestCase): - def test_fontfamily(self): + def test_fontfamily_initial(self): # Check initial font = FontFamily() self.assertEqual(str(font), '') self.assertEqual(repr(font), 'FontFamily()') self.assertEqual(len(font), 0) + def test_fontfamily_values(self): # Check value font = FontFamily(['Ahem']) self.assertEqual(str(font), 'Ahem') @@ -71,14 +91,19 @@ def test_fontfamily(self): self.assertEqual(repr(font), "FontFamily(['Ahem', 'White Space', 'serif'])") self.assertEqual(len(font), 3) + def test_fontfamily_get_item(self): # Check get item + font = FontFamily(['Ahem', 'White Space', 'serif']) self.assertEqual(font[0], 'Ahem') self.assertEqual(font[-1], 'serif') + def test_fontfamily_set_item(self): # Check immutable + font = FontFamily(['Ahem', 'White Space', 'serif']) with self.assertRaises(TypeError): font[0] = 'initial' + def test_fontfamily_equality(self): # Check equality font1 = FontFamily(['Ahem', 'serif']) font2 = FontFamily(['Ahem', 'serif']) @@ -86,10 +111,16 @@ def test_fontfamily(self): self.assertEqual(font1, font2) self.assertNotEqual(font1, font3) + def test_fontfamily_hash(self): # Check hash + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) self.assertEqual(hash(font1), hash(font2)) + def test_fontfamily_copy(self): # Check copy + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) self.assertNotEqual(id(font1), id(font2)) self.assertNotEqual(id(font1), id(font1.copy())) self.assertNotEqual(id(font2), id(font2.copy())) @@ -106,7 +137,7 @@ def test_shorthand(self): class FontShorthandTests(TestCase): - def test_font_shorthand(self): + def test_font_shorthand_initial(self): # Check initial font = FontShorthand() self.assertEqual(str(font), 'normal normal normal medium/normal initial') @@ -116,6 +147,7 @@ def test_font_shorthand(self): "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") ) + def test_font_shorthand_set_weight(self): font = FontShorthand(font_weight='bold') self.assertEqual(str(font), 'normal normal bold medium/normal initial') self.assertEqual( @@ -124,6 +156,7 @@ def test_font_shorthand(self): "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") ) + def test_font_shorthand_set_variant(self): font = FontShorthand(font_variant='small-caps') self.assertEqual(str(font), 'normal small-caps normal medium/normal initial') self.assertEqual( @@ -132,6 +165,7 @@ def test_font_shorthand(self): "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") ) + def test_font_shorthand_set_style(self): font = FontShorthand(font_style='oblique') self.assertEqual(str(font), 'oblique normal normal medium/normal initial') self.assertEqual( @@ -139,10 +173,22 @@ def test_font_shorthand(self): ("FontShorthand(font_style='oblique', font_variant='normal', font_weight='normal', " "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") ) + + def test_font_shorthand_invalid_key(self): # Check invalid key + font = FontShorthand() with self.assertRaises(KeyError): font['invalid-key'] = 2 + def test_font_shorthand_copy(self): # Copy + font = FontShorthand() self.assertEqual(font, font.copy()) self.assertNotEqual(id(font), id(font.copy())) + + def test_font_shorthand_iteration(self): + font = FontShorthand() + keys = [] + for prop in font: + keys.append(prop) + self.assertEqual(len(keys), 6) From 944e53f518198d2a5bb949a10076bd6fb634b5d6 Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Sun, 19 Jan 2020 23:17:47 -0500 Subject: [PATCH 30/32] Fix tests --- colosseum/validators.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/colosseum/validators.py b/colosseum/validators.py index cd5347454..be0ed4acf 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -115,7 +115,7 @@ def is_color(value): def is_font_family(values): """Validate that values are a valid list of font families.""" - from .constants import GENERIC_FAMILY_FONTS as generic_family + from .constants import GENERIC_FAMILY_FONTS, INITIAL FontDatabase = fonts.FontDatabase assert isinstance(values, Sequence) and not isinstance(values, str) @@ -141,7 +141,9 @@ def is_font_family(values): raise exceptions.ValidationError('Font family "{font_value}"' ' not found on system!'.format(font_value=no_quotes_val)) checked_values.append(no_quotes_val) - elif value in generic_family: + elif value in GENERIC_FAMILY_FONTS: + checked_values.append(value) + elif value in INITIAL: checked_values.append(value) else: error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=value) From b754c812deab14ad59ae903fb608cad743e151bf Mon Sep 17 00:00:00 2001 From: Gonzalo Pena-Castellanos Date: Mon, 27 Jan 2020 00:38:33 -0500 Subject: [PATCH 31/32] Fix code style --- colosseum/constants.py | 6 +++--- tests/test_validators.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index 28c49210a..16f4d0206 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,7 +1,7 @@ from .exceptions import ValidationError -from .validators import (ValidationError, is_border_spacing, is_color, - is_font_family, is_integer, is_length, is_number, - is_percentage, is_rect) +from .validators import (is_border_spacing, is_color, is_font_family, + is_integer, is_length, is_number, is_percentage, + is_rect) from .wrappers import FontFamily diff --git a/tests/test_validators.py b/tests/test_validators.py index 437cf5da3..a566e29e9 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -3,9 +3,8 @@ from colosseum.exceptions import ValidationError from colosseum.shapes import Rect from colosseum.units import px -from colosseum.validators import (ValidationError, is_border_spacing, - is_font_family, is_integer, is_number, - is_rect) +from colosseum.validators import (is_border_spacing, is_font_family, is_integer, + is_number, is_rect) class NumericTests(TestCase): From d824c86c30891b5ac0cf206650e809f5b619e8d5 Mon Sep 17 00:00:00 2001 From: goanpeca Date: Sun, 26 Apr 2020 22:31:43 -0500 Subject: [PATCH 32/32] Fix code style --- colosseum/constants.py | 1 - colosseum/declaration.py | 40 ---------------------------------------- colosseum/wrappers.py | 2 ++ tests/test_parser.py | 2 ++ tests/test_validators.py | 5 ++--- tests/test_wrappers.py | 2 ++ 6 files changed, 8 insertions(+), 44 deletions(-) diff --git a/colosseum/constants.py b/colosseum/constants.py index eeace0229..7b3cfc799 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -5,7 +5,6 @@ from .wrappers import FontFamily - class Choices: "A class to define allowable data types for a property." diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 18d1834c8..41fa48b8f 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -89,46 +89,6 @@ def deleter(self): return property(getter, setter, deleter) -def unvalidated_property(name, choices, initial): - "Define a simple CSS property attribute." - initial = choices.validate(initial) - - def getter(self): - # We return an immutable list subclass - return getattr(self, '_%s' % name, initial) - - def setter(self, value): - if isinstance(value, collections.Sequence) and not isinstance(value, str): - if add_quotes: - values = _add_quotes(value) - elif isinstance(value, str): - values = value.split(separator) - else: - raise ValueError('Invalid value for CSS property!') - - try: - values = storage_class(choices.validate(values)) - except ValueError: - raise ValueError("Invalid value '%s' for CSS property '%s'; Valid values are: %s" % ( - value, name, choices - )) - - if values != getattr(self, '_%s' % name, initial): - setattr(self, '_%s' % name, values) - self.dirty = True - - def deleter(self): - try: - delattr(self, '_%s' % name) - self.dirty = True - except AttributeError: - # Attribute doesn't exist - pass - - _CSS_PROPERTIES.add(name) - return property(getter, setter, deleter) - - def validated_property(name, choices, initial): "Define a simple CSS property attribute." try: diff --git a/colosseum/wrappers.py b/colosseum/wrappers.py index 27261e65e..090294068 100644 --- a/colosseum/wrappers.py +++ b/colosseum/wrappers.py @@ -193,6 +193,8 @@ def __init__(self, font_style='normal', font_variant='normal', font_weight='norm def __str__(self): string = '{font_style} {font_variant} {font_weight} {font_size}/{line_height} {font_family}' return string.format(**self._map) + + class Quotes: """ Content opening and closing quotes wrapper. diff --git a/tests/test_parser.py b/tests/test_parser.py index 604fcca4b..bdb891b1d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -516,6 +516,8 @@ def test_parse_font_shorthand_invalid_extras(font_property_string): with pytest.raises(Exception): print('Font: ' + font_property_string) parse_font(font_property_string) + + class ParseQuotesTests(TestCase): # Valid cases diff --git a/tests/test_validators.py b/tests/test_validators.py index 3f8d40397..14dcba11c 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -3,9 +3,8 @@ from colosseum.exceptions import ValidationError from colosseum.shapes import Rect from colosseum.units import px -from colosseum.validators import (ValidationError, is_border_spacing, - is_font_family, is_integer, is_number, - is_quote, is_rect) +from colosseum.validators import (is_border_spacing, is_font_family, + is_integer, is_number, is_quote, is_rect) from colosseum.wrappers import Quotes diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 4ea2e1e2d..0fe7dd554 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -247,6 +247,8 @@ def test_font_shorthand_iteration(self): for prop in font: keys.append(prop) self.assertEqual(len(keys), 6) + + class QuotesTests(TestCase): # Valid cases