xref: /aosp_15_r20/external/libxkbcommon/test/xkeyboard-config-test.py.in (revision 2b949d0487e80d67f1fda82db69e101e761f8064)
1#!/usr/bin/env python3
2import argparse
3import multiprocessing
4import sys
5import subprocess
6import os
7import xml.etree.ElementTree as ET
8from pathlib import Path
9
10
11verbose = False
12
13DEFAULT_RULES_XML = '@XKB_CONFIG_ROOT@/rules/evdev.xml'
14
15# Meson needs to fill this in so we can call the tool in the buildir.
16EXTRA_PATH = '@MESON_BUILD_ROOT@'
17os.environ['PATH'] = ':'.join([EXTRA_PATH, os.getenv('PATH')])
18
19
20def escape(s):
21    return s.replace('"', '\\"')
22
23
24# The function generating the progress bar (if any).
25def create_progress_bar(verbose):
26    def noop_progress_bar(x, total, file=None):
27        return x
28
29    progress_bar = noop_progress_bar
30    if not verbose and os.isatty(sys.stdout.fileno()):
31        try:
32            from tqdm import tqdm
33            progress_bar = tqdm
34        except ImportError:
35            pass
36
37    return progress_bar
38
39
40class Invocation:
41    def __init__(self, r, m, l, v, o):
42        self.command = ""
43        self.rules = r
44        self.model = m
45        self.layout = l
46        self.variant = v
47        self.option = o
48        self.exitstatus = 77  # default to skipped
49        self.error = None
50        self.keymap = None  # The fully compiled keymap
51
52    @property
53    def rmlvo(self):
54        return self.rules, self.model, self.layout, self.variant, self.option
55
56    def __str__(self):
57        s = []
58        rmlvo = [x or "" for x in self.rmlvo]
59        rmlvo = ', '.join([f'"{x}"' for x in rmlvo])
60        s.append(f'- rmlvo: [{rmlvo}]')
61        s.append(f'  cmd: "{escape(self.command)}"')
62        s.append(f'  status: {self.exitstatus}')
63        if self.error:
64            s.append(f'  error: "{escape(self.error.strip())}"')
65        return '\n'.join(s)
66
67    def run(self):
68        raise NotImplementedError
69
70
71class XkbCompInvocation(Invocation):
72    def run(self):
73        r, m, l, v, o = self.rmlvo
74        args = ['setxkbmap', '-print']
75        if r is not None:
76            args.append('-rules')
77            args.append('{}'.format(r))
78        if m is not None:
79            args.append('-model')
80            args.append('{}'.format(m))
81        if l is not None:
82            args.append('-layout')
83            args.append('{}'.format(l))
84        if v is not None:
85            args.append('-variant')
86            args.append('{}'.format(v))
87        if o is not None:
88            args.append('-option')
89            args.append('{}'.format(o))
90
91        xkbcomp_args = ['xkbcomp', '-xkb', '-', '-']
92
93        self.command = " ".join(args + ["|"] + xkbcomp_args)
94
95        setxkbmap = subprocess.Popen(args, stdout=subprocess.PIPE,
96                                     stderr=subprocess.PIPE, universal_newlines=True)
97        stdout, stderr = setxkbmap.communicate()
98        if "Cannot open display" in stderr:
99            self.error = stderr
100            self.exitstatus = 90
101        else:
102            xkbcomp = subprocess.Popen(xkbcomp_args, stdin=subprocess.PIPE,
103                                       stdout=subprocess.PIPE, stderr=subprocess.PIPE,
104                                       universal_newlines=True)
105            stdout, stderr = xkbcomp.communicate(stdout)
106            if xkbcomp.returncode != 0:
107                self.error = "failed to compile keymap"
108                self.exitstatus = xkbcomp.returncode
109            else:
110                self.keymap = stdout
111                self.exitstatus = 0
112
113
114class XkbcommonInvocation(Invocation):
115    def run(self):
116        r, m, l, v, o = self.rmlvo
117        args = [
118            'xkbcli-compile-keymap',  # this is run in the builddir
119            '--verbose',
120            '--rules', r,
121            '--model', m,
122            '--layout', l,
123        ]
124        if v is not None:
125            args += ['--variant', v]
126        if o is not None:
127            args += ['--options', o]
128
129        self.command = " ".join(args)
130        try:
131            output = subprocess.check_output(args, stderr=subprocess.STDOUT,
132                                             universal_newlines=True)
133            if "unrecognized keysym" in output:
134                for line in output.split('\n'):
135                    if "unrecognized keysym" in line:
136                        self.error = line
137                self.exitstatus = 99  # tool doesn't generate this one
138            else:
139                self.exitstatus = 0
140                self.keymap = output
141        except subprocess.CalledProcessError as err:
142            self.error = "failed to compile keymap"
143            self.exitstatus = err.returncode
144
145
146def xkbcommontool(rmlvo):
147    try:
148        r = rmlvo.get('r', 'evdev')
149        m = rmlvo.get('m', 'pc105')
150        l = rmlvo.get('l', 'us')
151        v = rmlvo.get('v', None)
152        o = rmlvo.get('o', None)
153        tool = XkbcommonInvocation(r, m, l, v, o)
154        tool.run()
155        return tool
156    except KeyboardInterrupt:
157        pass
158
159
160def xkbcomp(rmlvo):
161    try:
162        r = rmlvo.get('r', 'evdev')
163        m = rmlvo.get('m', 'pc105')
164        l = rmlvo.get('l', 'us')
165        v = rmlvo.get('v', None)
166        o = rmlvo.get('o', None)
167        tool = XkbCompInvocation(r, m, l, v, o)
168        tool.run()
169        return tool
170    except KeyboardInterrupt:
171        pass
172
173
174def parse(path):
175    root = ET.fromstring(open(path).read())
176    layouts = root.findall('layoutList/layout')
177
178    options = [
179        e.text
180        for e in root.findall('optionList/group/option/configItem/name')
181    ]
182
183    combos = []
184    for l in layouts:
185        layout = l.find('configItem/name').text
186        combos.append({'l': layout})
187
188        variants = l.findall('variantList/variant')
189        for v in variants:
190            variant = v.find('configItem/name').text
191
192            combos.append({'l': layout, 'v': variant})
193            for option in options:
194                combos.append({'l': layout, 'v': variant, 'o': option})
195
196    return combos
197
198
199def run(combos, tool, njobs, keymap_output_dir):
200    if keymap_output_dir:
201        keymap_output_dir = Path(keymap_output_dir)
202        try:
203            keymap_output_dir.mkdir()
204        except FileExistsError as e:
205            print(e, file=sys.stderr)
206            return False
207
208    keymap_file = None
209    keymap_file_fd = None
210
211    failed = False
212    with multiprocessing.Pool(njobs) as p:
213        results = p.imap_unordered(tool, combos)
214        for invocation in progress_bar(results, total=len(combos), file=sys.stdout):
215            if invocation.exitstatus != 0:
216                failed = True
217                target = sys.stderr
218            else:
219                target = sys.stdout if verbose else None
220
221            if target:
222                print(invocation, file=target)
223
224            if keymap_output_dir:
225                # we're running through the layouts in a somewhat sorted manner,
226                # so let's keep the fd open until we switch layouts
227                layout = invocation.layout
228                if invocation.variant:
229                    layout += f"({invocation.variant})"
230                fname = keymap_output_dir / layout
231                if fname != keymap_file:
232                    keymap_file = fname
233                    if keymap_file_fd:
234                        keymap_file_fd.close()
235                    keymap_file_fd = open(keymap_file, 'a')
236
237                rmlvo = ', '.join([x or '' for x in invocation.rmlvo])
238                print(f"// {rmlvo}", file=keymap_file_fd)
239                print(invocation.keymap, file=keymap_file_fd)
240                keymap_file_fd.flush()
241
242    return failed
243
244
245def main(args):
246    global progress_bar
247    global verbose
248
249    tools = {
250        'libxkbcommon': xkbcommontool,
251        'xkbcomp': xkbcomp,
252    }
253
254    parser = argparse.ArgumentParser(
255        description='''
256                    This tool compiles a keymap for each layout, variant and
257                    options combination in the given rules XML file. The output
258                    of this tool is YAML, use your favorite YAML parser to
259                    extract error messages. Errors are printed to stderr.
260                    '''
261    )
262    parser.add_argument('path', metavar='/path/to/evdev.xml',
263                        nargs='?', type=str,
264                        default=DEFAULT_RULES_XML,
265                        help='Path to xkeyboard-config\'s evdev.xml')
266    parser.add_argument('--tool', choices=tools.keys(),
267                        type=str, default='libxkbcommon',
268                        help='parsing tool to use')
269    parser.add_argument('--jobs', '-j', type=int,
270                        default=os.cpu_count() * 4,
271                        help='number of processes to use')
272    parser.add_argument('--verbose', '-v', default=False, action="store_true")
273    parser.add_argument('--keymap-output-dir', default=None, type=str,
274                        help='Directory to print compiled keymaps to')
275    parser.add_argument('--layout', default=None, type=str,
276                        help='Only test the given layout')
277    parser.add_argument('--variant', default=None, type=str,
278                        help='Only test the given variant')
279    parser.add_argument('--option', default=None, type=str,
280                        help='Only test the given option')
281
282    args = parser.parse_args()
283
284    verbose = args.verbose
285    keymapdir = args.keymap_output_dir
286    progress_bar = create_progress_bar(verbose)
287
288    tool = tools[args.tool]
289
290    if any([args.layout, args.variant, args.option]):
291        combos = [{
292            'l': args.layout,
293            'v': args.variant,
294            'o': args.option,
295        }]
296    else:
297        combos = parse(args.path)
298    failed = run(combos, tool, args.jobs, keymapdir)
299    sys.exit(failed)
300
301
302if __name__ == '__main__':
303    try:
304        main(sys.argv)
305    except KeyboardInterrupt:
306        print('# Exiting after Ctrl+C')
307