#!/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, file=None): 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), 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): 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') 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')