test: show the tool exit status on failure
[platform/upstream/libxkbcommon.git] / test / xkeyboard-config-test.py.in
index 345ebd5..66deca4 100755 (executable)
-#!/usr/bin/env python
+#!/usr/bin/env python3
+import argparse
+import multiprocessing
 import sys
 import subprocess
 import os
 import xml.etree.ElementTree as ET
+from pathlib import Path
 
 
-verbose = True
+verbose = False
 
 DEFAULT_RULES_XML = '@XKB_CONFIG_ROOT@/rules/evdev.xml'
 
 # Meson needs to fill this in so we can call the tool in the buildir.
-EXTRA_PATH='@MESON_BUILD_ROOT@'
+EXTRA_PATH = '@MESON_BUILD_ROOT@'
 os.environ['PATH'] = ':'.join([EXTRA_PATH, os.getenv('PATH')])
 
 
+def escape(s):
+    return s.replace('"', '\\"')
+
+
 # The function generating the progress bar (if any).
-progress_bar = lambda x, desc: x
-if os.isatty(sys.stdout.fileno()):
-    try:
-        from tqdm import tqdm
-        progress_bar = tqdm
+def create_progress_bar(verbose):
+    def noop_progress_bar(x, total, file=None):
+        return x
 
-        verbose = False
-    except ImportError:
-        pass
+    progress_bar = noop_progress_bar
+    if not verbose and os.isatty(sys.stdout.fileno()):
+        try:
+            from tqdm import tqdm
+            progress_bar = tqdm
+        except ImportError:
+            pass
 
+    return progress_bar
 
-def xkbcommontool(r='evdev', m='pc105', l='us', v=None, o=None):
-    args = [
-        'rmlvo-to-keymap',
-        '--rules', r,
-        '--model', m,
-        '--layout', l,
-    ]
-    if v is not None:
-        args += ['--variant', v]
-    if o is not None:
-        args += ['--options', o]
 
-    if verbose:
-        print(':: {}'.format(' '.join(args)))
+class Invocation:
+    def __init__(self, r, m, l, v, o):
+        self.command = ""
+        self.rules = r
+        self.model = m
+        self.layout = l
+        self.variant = v
+        self.option = o
+        self.exitstatus = 77  # default to skipped
+        self.error = None
+        self.keymap = None  # The fully compiled keymap
 
-    try:
-        output = subprocess.check_output(args, stderr=subprocess.STDOUT)
-        if verbose:
-            print(output.decode('utf-8'))
-    except subprocess.CalledProcessError as err:
-        print('ERROR: Failed to compile: {}'.format(' '.join(args)))
-        print(err.output.decode('utf-8'))
-        sys.exit(1)
-
-
-def xkbcomp(r='evdev', m='pc105', l='us', v='', o=''):
-    args = ['setxkbmap', '-print']
-    if r is not None:
-        args.append('-rules')
-        args.append('{}'.format(r))
-    if m is not None:
-        args.append('-model')
-        args.append('{}'.format(m))
-    if l is not None:
-        args.append('-layout')
-        args.append('{}'.format(l))
-    if o is not None:
-        args.append('-option')
-        args.append('{}'.format(o))
-
-    if verbose:
-        print(':: {}'.format(' '.join(args)))
+    @property
+    def rmlvo(self):
+        return self.rules, self.model, self.layout, self.variant, self.option
+
+    def __str__(self):
+        s = []
+        rmlvo = [x or "" for x in self.rmlvo]
+        rmlvo = ', '.join([f'"{x}"' for x in rmlvo])
+        s.append(f'- rmlvo: [{rmlvo}]')
+        s.append(f'  cmd: "{escape(self.command)}"')
+        s.append(f'  status: {self.exitstatus}')
+        if self.error:
+            s.append(f'  error: "{escape(self.error.strip())}"')
+        return '\n'.join(s)
+
+    def run(self):
+        raise NotImplementedError
+
+
+class XkbCompInvocation(Invocation):
+    def run(self):
+        r, m, l, v, o = self.rmlvo
+        args = ['setxkbmap', '-print']
+        if r is not None:
+            args.append('-rules')
+            args.append('{}'.format(r))
+        if m is not None:
+            args.append('-model')
+            args.append('{}'.format(m))
+        if l is not None:
+            args.append('-layout')
+            args.append('{}'.format(l))
+        if v is not None:
+            args.append('-variant')
+            args.append('{}'.format(v))
+        if o is not None:
+            args.append('-option')
+            args.append('{}'.format(o))
 
-    try:
         xkbcomp_args = ['xkbcomp', '-xkb', '-', '-']
 
-        setxkbmap = subprocess.Popen(args, stdout=subprocess.PIPE)
-        xkbcomp = subprocess.Popen(xkbcomp_args, stdin=setxkbmap.stdout,
-                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-        setxkbmap.stdout.close()
-        stdout, stderr = xkbcomp.communicate()
-        if xkbcomp.returncode != 0:
-            print('ERROR: Failed to compile: {}'.format(' '.join(args)))
-        if xkbcomp.returncode != 0 or verbose:
-            print(stdout.decode('utf-8'))
-            print(stderr.decode('utf-8'))
+        self.command = " ".join(args + ["|"] + xkbcomp_args)
+
+        setxkbmap = subprocess.Popen(args, stdout=subprocess.PIPE,
+                                     stderr=subprocess.PIPE, universal_newlines=True)
+        stdout, stderr = setxkbmap.communicate()
+        if "Cannot open display" in stderr:
+            self.error = stderr
+            self.exitstatus = 90
+        else:
+            xkbcomp = subprocess.Popen(xkbcomp_args, stdin=subprocess.PIPE,
+                                       stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+                                       universal_newlines=True)
+            stdout, stderr = xkbcomp.communicate(stdout)
+            if xkbcomp.returncode != 0:
+                self.error = "failed to compile keymap"
+                self.exitstatus = xkbcomp.returncode
+            else:
+                self.keymap = stdout
+                self.exitstatus = 0
+
+
+class XkbcommonInvocation(Invocation):
+    def run(self):
+        r, m, l, v, o = self.rmlvo
+        args = [
+            'xkbcli-compile-keymap',  # this is run in the builddir
+            '--verbose',
+            '--rules', r,
+            '--model', m,
+            '--layout', l,
+        ]
+        if v is not None:
+            args += ['--variant', v]
+        if o is not None:
+            args += ['--options', o]
+
+        self.command = " ".join(args)
+        try:
+            output = subprocess.check_output(args, stderr=subprocess.STDOUT,
+                                             universal_newlines=True)
+            if "unrecognized keysym" in output:
+                for line in output.split('\n'):
+                    if "unrecognized keysym" in line:
+                        self.error = line
+                self.exitstatus = 99  # tool doesn't generate this one
+            else:
+                self.exitstatus = 0
+                self.keymap = output
+        except subprocess.CalledProcessError as err:
+            self.error = "failed to compile keymap"
+            self.exitstatus = err.returncode
 
-    # This catches setxkbmap errors.
-    except subprocess.CalledProcessError as err:
-        print('ERROR: Failed to compile: {}'.format(' '.join(args)))
-        print(err.output.decode('utf-8'))
+
+def xkbcommontool(rmlvo):
+    try:
+        r = rmlvo.get('r', 'evdev')
+        m = rmlvo.get('m', 'pc105')
+        l = rmlvo.get('l', 'us')
+        v = rmlvo.get('v', None)
+        o = rmlvo.get('o', None)
+        tool = XkbcommonInvocation(r, m, l, v, o)
+        tool.run()
+        return tool
+    except KeyboardInterrupt:
+        pass
 
 
-def parse(root):
+def xkbcomp(rmlvo):
+    try:
+        r = rmlvo.get('r', 'evdev')
+        m = rmlvo.get('m', 'pc105')
+        l = rmlvo.get('l', 'us')
+        v = rmlvo.get('v', None)
+        o = rmlvo.get('o', None)
+        tool = XkbCompInvocation(r, m, l, v, o)
+        tool.run()
+        return tool
+    except KeyboardInterrupt:
+        pass
+
+
+def parse(path):
+    root = ET.fromstring(open(path).read())
     layouts = root.findall('layoutList/layout')
 
     options = [
@@ -97,33 +180,127 @@ def parse(root):
         for e in root.findall('optionList/group/option/configItem/name')
     ]
 
-    # Switch this to xkbcomp if needed.
-    tool = xkbcommontool
-    # tool = xkbcomp
-
-    for l in progress_bar(layouts, 'layout '):
+    combos = []
+    for l in layouts:
         layout = l.find('configItem/name').text
-        tool(l=layout)
+        combos.append({'l': layout})
 
         variants = l.findall('variantList/variant')
-        for v in progress_bar(variants, 'variant'):
+        for v in variants:
             variant = v.find('configItem/name').text
-            tool(l=layout, v=variant)
 
-            for option in progress_bar(options, 'option '):
-                tool(l=layout, v=variant, o=option)
+            combos.append({'l': layout, 'v': variant})
+            for option in options:
+                combos.append({'l': layout, 'v': variant, 'o': option})
+
+    return combos
+
+
+def run(combos, tool, njobs, keymap_output_dir):
+    if keymap_output_dir:
+        keymap_output_dir = Path(keymap_output_dir)
+        try:
+            keymap_output_dir.mkdir()
+        except FileExistsError as e:
+            print(e, file=sys.stderr)
+            return False
+
+    keymap_file = None
+    keymap_file_fd = None
+
+    failed = False
+    with multiprocessing.Pool(njobs) as p:
+        results = p.imap_unordered(tool, combos)
+        for invocation in progress_bar(results, total=len(combos), file=sys.stdout):
+            if invocation.exitstatus != 0:
+                failed = True
+                target = sys.stderr
+            else:
+                target = sys.stdout if verbose else None
+
+            if target:
+                print(invocation, file=target)
+
+            if keymap_output_dir:
+                # we're running through the layouts in a somewhat sorted manner,
+                # so let's keep the fd open until we switch layouts
+                layout = invocation.layout
+                if invocation.variant:
+                    layout += f"({invocation.variant})"
+                fname = keymap_output_dir / layout
+                if fname != keymap_file:
+                    keymap_file = fname
+                    if keymap_file_fd:
+                        keymap_file_fd.close()
+                    keymap_file_fd = open(keymap_file, 'a')
+
+                rmlvo = ', '.join([x or '' for x in invocation.rmlvo])
+                print(f"// {rmlvo}", file=keymap_file_fd)
+                print(invocation.keymap, file=keymap_file_fd)
+                keymap_file_fd.flush()
+
+    return failed
 
 
 def main(args):
-    try:
-        path = args[1]
-    except IndexError:
-        path = DEFAULT_RULES_XML
+    global progress_bar
+    global verbose
+
+    tools = {
+        'libxkbcommon': xkbcommontool,
+        'xkbcomp': xkbcomp,
+    }
+
+    parser = argparse.ArgumentParser(
+        description='''
+                    This tool compiles a keymap for each layout, variant and
+                    options combination in the given rules XML file. The output
+                    of this tool is YAML, use your favorite YAML parser to
+                    extract error messages. Errors are printed to stderr.
+                    '''
+    )
+    parser.add_argument('path', metavar='/path/to/evdev.xml',
+                        nargs='?', type=str,
+                        default=DEFAULT_RULES_XML,
+                        help='Path to xkeyboard-config\'s evdev.xml')
+    parser.add_argument('--tool', choices=tools.keys(),
+                        type=str, default='libxkbcommon',
+                        help='parsing tool to use')
+    parser.add_argument('--jobs', '-j', type=int,
+                        default=os.cpu_count() * 4,
+                        help='number of processes to use')
+    parser.add_argument('--verbose', '-v', default=False, action="store_true")
+    parser.add_argument('--keymap-output-dir', default=None, type=str,
+                        help='Directory to print compiled keymaps to')
+    parser.add_argument('--layout', default=None, type=str,
+                        help='Only test the given layout')
+    parser.add_argument('--variant', default=None, type=str,
+                        help='Only test the given variant')
+    parser.add_argument('--option', default=None, type=str,
+                        help='Only test the given option')
 
-    with open(path) as f:
-        root = ET.fromstring(f.read())
-        parse(root)
+    args = parser.parse_args()
+
+    verbose = args.verbose
+    keymapdir = args.keymap_output_dir
+    progress_bar = create_progress_bar(verbose)
+
+    tool = tools[args.tool]
+
+    if any([args.layout, args.variant, args.option]):
+        combos = [{
+            'l': args.layout,
+            'v': args.variant,
+            'o': args.option,
+        }]
+    else:
+        combos = parse(args.path)
+    failed = run(combos, tool, args.jobs, keymapdir)
+    sys.exit(failed)
 
 
 if __name__ == '__main__':
-    main(sys.argv)
+    try:
+        main(sys.argv)
+    except KeyboardInterrupt:
+        print('# Exiting after Ctrl+C')