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