#!/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 = 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@' os.environ['PATH'] = ':'.join([EXTRA_PATH, os.getenv('PATH')]) def escape(s): return s.replace('"', '\\"') # The function generating the progress bar (if any). def create_progress_bar(verbose): def noop_progress_bar(x, total): return x 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 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 @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)) xkbcomp_args = ['xkbcomp', '-xkb', '-', '-'] 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 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 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 = [ e.text for e in root.findall('optionList/group/option/configItem/name') ] combos = [] for l in layouts: layout = l.find('configItem/name').text combos.append({'l': layout}) variants = l.findall('variantList/variant') for v in variants: variant = v.find('configItem/name').text 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)): 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): global progress_bar global verbose tools = { 'libxkbcommon': xkbcommontool, 'xkbcomp': xkbcomp, } parser = argparse.ArgumentParser( description='Tool to test all layout/variant/option combinations.' ) 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') 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__': try: main(sys.argv) except KeyboardInterrupt: print('# Exiting after Ctrl+C')