Interactive tools: Escape control character for Unicode output
[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='''
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
302 if __name__ == '__main__':
303     try:
304         main(sys.argv)
305     except KeyboardInterrupt:
306         print('# Exiting after Ctrl+C')