1#!/usr/bin/env python3 2# 3# Call with pytest. Requires XKB_CONFIG_ROOT to be set 4 5import os 6import pytest 7from pathlib import Path 8import xml.etree.ElementTree as ET 9 10 11def _xkb_config_root(): 12 path = os.getenv('XKB_CONFIG_ROOT') 13 assert path is not None, 'Environment variable XKB_CONFIG_ROOT must be set' 14 print(f'Using XKB_CONFIG_ROOT={path}') 15 16 xkbpath = Path(path) 17 assert (xkbpath / 'rules').exists(), f'{path} is not an XKB installation' 18 return xkbpath 19 20 21@pytest.fixture 22def xkb_config_root(): 23 return _xkb_config_root() 24 25 26def iterate_layouts_variants(rules_xml): 27 ''' 28 Return an iterator of type (layout, variant) for each element in the XML 29 file. 30 ''' 31 tree = ET.parse(rules_xml) 32 root = tree.getroot() 33 for layout in root.iter('layout'): 34 yield layout, None 35 36 for variant in layout.iter('variant'): 37 yield layout, variant 38 39def iterate_config_items(rules_xml): 40 ''' 41 Return an iterator of configItem elements 42 ''' 43 tree = ET.parse(rules_xml) 44 root = tree.getroot() 45 return root.iter('configItem') 46 47 48def pytest_generate_tests(metafunc): 49 # for any test_foo function with an argument named rules_xml, 50 # make it the list of XKB_CONFIG_ROOT/rules/*.xml files. 51 if 'rules_xml' in metafunc.fixturenames: 52 rules_xml = list(_xkb_config_root().glob('rules/*.xml')) 53 assert rules_xml 54 metafunc.parametrize('rules_xml', rules_xml) 55 # for any test_foo function with an argument named layout, 56 # make it a Layout wrapper class for all layout(variant) combinations 57 elif 'layout' in metafunc.fixturenames: 58 rules_xml = list(_xkb_config_root().glob('rules/*.xml')) 59 assert rules_xml 60 layouts = [] 61 for f in rules_xml: 62 for l, v in iterate_layouts_variants(f): 63 layouts.append(Layout(f, l, v)) 64 metafunc.parametrize('layout', layouts) 65 elif 'config_item' in metafunc.fixturenames: 66 rules_xml = list(_xkb_config_root().glob('rules/*.xml')) 67 assert rules_xml 68 config_items = [] 69 for f in rules_xml: 70 for item in iterate_config_items(f): 71 item = ConfigItem.from_elem(item) 72 item.rulesfile = f 73 config_items.append(item) 74 metafunc.parametrize('config_item', config_items) 75 76 77 78 79class Layout: 80 ''' 81 Wrapper class for layout/variants - both ConfigItems are available but 82 the properties automatically pick the variant (if it exists) or the 83 layout otherwise. 84 ''' 85 def __init__(self, rulesfile, layout, variant=None): 86 self.rulesfile = rulesfile 87 self.layout = ConfigItem.from_elem(layout) 88 self.variant = ConfigItem.from_elem(variant) if variant else None 89 if variant: 90 self.name = f"{self.layout.name}({self.variant.name})" 91 else: 92 self.name = f"{self.layout.name}" 93 94 @property 95 def iso3166(self): 96 if self.variant and self.variant.iso3166 is not None: 97 return self.variant.iso3166 or [] 98 # inherit from parent 99 return self.layout.iso3166 or [] 100 101 @property 102 def iso639(self): 103 if self.variant and self.variant.iso639 is not None: 104 return self.variant.iso639 or [] 105 # inherit from parent 106 return self.layout.iso639 or [] 107 108 @property 109 def popularity(self): 110 return self.variant.popularity if self.variant else self.layout.popularity 111 112 @property 113 def shortDescription(self): 114 if self.variant and self.variant.shortDescription: 115 return self.variant.shortDescription 116 return self.layout.shortDescription 117 118 119def prettyxml(element): 120 return ET.tostring(element).decode('utf-8') 121 122 123class ConfigItem: 124 def __init__(self, name, shortDescription=None, description=None): 125 self.name = name 126 self.shortDescription = shortDescription 127 self.description = description 128 self.iso639 = None 129 self.iso3166 = None 130 self.popularity = None 131 132 @classmethod 133 def _fetch_subelement(cls, parent, name): 134 sub_element = parent.findall(name) 135 if sub_element is not None and len(sub_element) == 1: 136 return sub_element[0] 137 else: 138 return None 139 140 @classmethod 141 def _fetch_subelement_text(cls, parent, name): 142 sub_element = parent.findall(name) 143 return [e.text for e in sub_element] 144 145 @classmethod 146 def _fetch_text(cls, parent, name): 147 sub_element = cls._fetch_subelement(parent, name) 148 if sub_element is None: 149 return None 150 return sub_element.text 151 152 @classmethod 153 def from_elem(cls, elem): 154 try: 155 ci_element = elem if elem.tag == "configItem" else cls._fetch_subelement(elem, 'configItem') 156 name = cls._fetch_text(ci_element, 'name') 157 assert name is not None 158 # shortDescription and description are optional 159 sdesc = cls._fetch_text(ci_element, 'shortDescription') 160 desc = cls._fetch_text(ci_element, 'description') 161 ci = ConfigItem(name, sdesc, desc) 162 ci.popularity = ci_element.attrib.get('popularity') 163 164 langlist = cls._fetch_subelement(ci_element, 'languageList') 165 if langlist: 166 ci.iso639 = cls._fetch_subelement_text(langlist, 'iso639Id') 167 168 countrylist = cls._fetch_subelement(ci_element, 'countryList') 169 if countrylist: 170 ci.iso3166 = cls._fetch_subelement_text(countrylist, 'iso3166Id') 171 172 return ci 173 except AssertionError as e: 174 endl = "\n" # f{} cannot contain backslashes 175 e.args = (f'\nFor element {prettyxml(elem)}\n{endl.join(e.args)}',) 176 raise 177 178 179def test_duplicate_layouts(rules_xml): 180 tree = ET.parse(rules_xml) 181 root = tree.getroot() 182 layouts = {} 183 for layout in root.iter('layout'): 184 ci = ConfigItem.from_elem(layout) 185 assert ci.name not in layouts, f'Duplicate layout {ci.name}' 186 layouts[ci.name] = True 187 188 variants = {} 189 for variant in layout.iter('variant'): 190 vci = ConfigItem.from_elem(variant) 191 assert vci.name not in variants, \ 192 f'{rules_xml}: duplicate variant {ci.name}({vci.name}):\n{prettyxml(variant)}' 193 variants[vci.name] = True 194 195 196def test_duplicate_models(rules_xml): 197 tree = ET.parse(rules_xml) 198 root = tree.getroot() 199 models = {} 200 for model in root.iter('model'): 201 ci = ConfigItem.from_elem(model) 202 assert ci.name not in models, f'Duplicate model {ci.name}' 203 models[ci.name] = True 204 205 206def test_exotic(config_item): 207 """All items in extras should be marked exotic""" 208 if config_item.rulesfile.stem.endswith('extras'): 209 assert config_item.popularity == "exotic", f"{config_item.rulesfile}: item {config_item.name} does not have popularity exotic" 210 else: 211 assert config_item.popularity != "exotic", f"{config_item.rulesfile}: item {config_item.name} has popularity exotic" 212 213 214def test_short_description(layout): 215 assert layout.shortDescription, f'{layout.rulesfile}: layout {layout.name} missing shortDescription' 216 217 218def test_iso3166(layout): 219 """Typically layouts should specify at least one country code""" 220 pycountry = pytest.importorskip('pycountry') 221 country_codes = [c.alpha_2 for c in pycountry.countries] 222 expected_without_country = [ 223 "apl", "bqn", # programming 224 "brai", # Braille not specific to any country 225 "custom", 226 "epo", # Esperanto not native to any country 227 "trans", # international 228 ] 229 230 for code in layout.iso3166: 231 assert code in country_codes, \ 232 f'{layout.rulesfile}: unknown country code "{code}" in {layout.name}' 233 234 assert layout.iso3166 or layout.layout.name in expected_without_country, f"{layout.rulesfile}: layout {layout.name} has no countries associated" 235 236 237def test_iso639(layout): 238 """Typically layouts should should specify at least one language code""" 239 pycountry = pytest.importorskip('pycountry') 240 241 # A list of languages not in pycountry, so we need to special-case them 242 special_langs = [ 243 'ber', # Berber languages (collective), https://iso639-3.sil.org/code/ber 244 'btb', # Beti (Cameroon), https://iso639-3.sil.org/code/btb 245 'fox', # Formosan languages (collective), https://iso639-3.sil.org/code/fox 246 'phi', # Philippine languages (collective), https://iso639-3.sil.org/code/phi 247 'ovd', # Elfdalian, https://iso639-3.sil.org/code/ovd 248 ] 249 language_codes = [c.alpha_3 for c in pycountry.languages] + special_langs 250 expected_without_language = ["brai", "custom", "trans"] 251 252 for code in layout.iso639: 253 assert code in language_codes, \ 254 f'{layout.rulesfile}: unknown language code "{code}" in {layout.name}' 255 256 assert layout.iso639 or layout.layout.name in expected_without_language, f"{layout.rulesfile}: layout {layout.name} has no languages associated" 257