test: print the layout-tester progress bar to stdout by default
[platform/upstream/libxkbcommon.git] / test / xkeyboard-config-test.py.in
1 #!/usr/bin/env python3
2 import argparse
3 import multiprocessing
4 import sys
5 import subprocess
6 import os
7 import xml.etree.ElementTree as ET
8 from pathlib import Path
9
10
11 verbose = False
12
13 DEFAULT_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.
16 EXTRA_PATH = '@MESON_BUILD_ROOT@'
17 os.environ['PATH'] = ':'.join([EXTRA_PATH, os.getenv('PATH')])
18
19
20 def escape(s):
21     return s.replace('"', '\\"')
22
23
24 # The function generating the progress bar (if any).
25 def 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
40 class 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
71 class 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
114 class 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
146 def 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
160 def 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
174 def 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
199 def 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
245 def 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='Tool to test all layout/variant/option combinations.'
256     )
257     parser.add_argument('path', metavar='/path/to/evdev.xml',
258                         nargs='?', type=str,
259                         default=DEFAULT_RULES_XML,
260                         help='Path to xkeyboard-config\'s evdev.xml')
261     parser.add_argument('--tool', choices=tools.keys(),
262                         type=str, default='libxkbcommon',
263                         help='parsing tool to use')
264     parser.add_argument('--jobs', '-j', type=int,
265                         default=os.cpu_count() * 4,
266                         help='number of processes to use')
267     parser.add_argument('--verbose', '-v', default=False, action="store_true")
268     parser.add_argument('--keymap-output-dir', default=None, type=str,
269                         help='Directory to print compiled keymaps to')
270     parser.add_argument('--layout', default=None, type=str,
271                         help='Only test the given layout')
272     parser.add_argument('--variant', default=None, type=str,
273                         help='Only test the given variant')
274     parser.add_argument('--option', default=None, type=str,
275                         help='Only test the given option')
276
277     args = parser.parse_args()
278
279     verbose = args.verbose
280     keymapdir = args.keymap_output_dir
281     progress_bar = create_progress_bar(verbose)
282
283     tool = tools[args.tool]
284
285     if any([args.layout, args.variant, args.option]):
286         combos = [{
287             'l': args.layout,
288             'v': args.variant,
289             'o': args.option,
290         }]
291     else:
292         combos = parse(args.path)
293     failed = run(combos, tool, args.jobs, keymapdir)
294     sys.exit(failed)
295
296
297 if __name__ == '__main__':
298     try:
299         main(sys.argv)
300     except KeyboardInterrupt:
301         print('# Exiting after Ctrl+C')