From 1fc29e6c8a5b23495f34ca604de8c2d80a694118 Mon Sep 17 00:00:00 2001 From: Etienne Monier Date: Wed, 31 Jan 2024 17:07:22 +0100 Subject: [PATCH 1/6] refactor: Switch to pathlib and implement annotations --- kalamine/cli.py | 201 ++++++++++++++++------------- kalamine/cli_xkb.py | 44 ++++--- kalamine/layout.py | 111 ++++++++-------- kalamine/server.py | 8 +- kalamine/template.py | 46 +++---- kalamine/utils.py | 19 ++- kalamine/xkb_manager.py | 87 +++++++------ tests/__init__.py | 0 tests/test_macos.py | 6 +- tests/test_parser.py | 6 +- tests/test_serializer_ahk.py | 7 +- tests/test_serializer_keylayout.py | 5 +- tests/test_serializer_klc.py | 5 +- tests/test_serializer_xkb.py | 5 +- tests/util.py | 7 + 15 files changed, 303 insertions(+), 254 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/util.py diff --git a/kalamine/cli.py b/kalamine/cli.py index d0181d7..e291b91 100644 --- a/kalamine/cli.py +++ b/kalamine/cli.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 import json -import os -import shutil +from contextlib import contextmanager from importlib import metadata from pathlib import Path +from typing import List, Literal, Union import click @@ -12,77 +12,96 @@ @click.group() -def cli(): - pass +def cli() -> None: + ... -def pretty_json(layout, path): - """Pretty-prints the JSON layout.""" +def pretty_json(layout: KeyboardLayout, output_path: Path) -> None: + """Pretty-print the JSON layout. + + Parameters + ---------- + layout : KeyboardLayout + The layout to be exported. + output_path : Path + The output file path. + """ text = ( json.dumps(layout.json, indent=2, ensure_ascii=False) .replace("\n ", " ") .replace("\n ]", " ]") .replace("\n }", " }") ) - with open(path, "w", encoding="utf8") as file: - file.write(text) + output_path.write_text(text, encoding="utf8") + +def make_all(layout: KeyboardLayout, output_dir_path: Path) -> None: + """Generate all layout output files. -def make_all(layout, subdir): - def out_path(ext=""): - return os.path.join(subdir, layout.meta["fileName"] + ext) + Parameters + ---------- + layout : KeyboardLayout + The layout to process. + output_dir_path : Path + The output directory. + """ - if not os.path.exists(subdir): - os.makedirs(subdir) + @contextmanager + def file_creation_context(ext: str = "") -> Path: + """Generate an output file path for extension EXT, return it and finally echo info.""" + path = output_dir_path / (layout.meta["fileName"] + ext) + yield path + click.echo(f"... {path}") + + if not output_dir_path.exists(): + output_dir_path.mkdir(parents=True) # AHK driver - ahk_path = out_path(".ahk") - with open(ahk_path, "w", encoding="utf-8", newline="\n") as file: - file.write("\uFEFF") # AHK scripts require a BOM - file.write(layout.ahk) - print("... " + ahk_path) + with file_creation_context(".ahk") as ahk_path: + with ahk_path.open("w", encoding="utf-8", newline="\n") as file: + file.write("\uFEFF") # AHK scripts require a BOM + file.write(layout.ahk) # Windows driver - klc_path = out_path(".klc") - with open(klc_path, "w", encoding="utf-16le", newline="\r\n") as file: - file.write(layout.klc) - print("... " + klc_path) + with file_creation_context(".klc") as klc_path: + klc_path.write_text(layout.klc, encoding="utf-16le", newline="\r\n") # macOS driver - osx_path = out_path(".keylayout") - with open(osx_path, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.keylayout) - print("... " + osx_path) + with file_creation_context(".keylayout") as osx_path: + osx_path.write_text(layout.keylayout, encoding="utf-8", newline="\n") # Linux driver, user-space - xkb_path = out_path(".xkb") - with open(xkb_path, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.xkb) - print("... " + xkb_path) + with file_creation_context(".xkb") as xkb_path: + xkb_path.write_text(layout.xkb, encoding="utf-8", newline="\n") # Linux driver, root - xkb_custom_path = out_path(".xkb_custom") - with open(xkb_custom_path, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.xkb_patch) - print("... " + xkb_custom_path) + with file_creation_context(".xkb_custom") as xkb_custom_path: + xkb_custom_path.write_text( + layout.xkb_patch, "w", encoding="utf-8", newline="\n" + ) # JSON data - json_path = out_path(".json") - pretty_json(layout, json_path) - print("... " + json_path) + with file_creation_context(".json") as json_path: + pretty_json(layout, json_path) # SVG data - svg_path = out_path(".svg") - layout.svg.write(svg_path, pretty_print=True, encoding="utf-8") - print("... " + svg_path) + with file_creation_context(".svg") as svg_path: + layout.svg.write(svg_path, pretty_print=True, encoding="utf-8") @cli.command() -@click.argument("layout_descriptors", nargs=-1, type=click.Path(exists=True)) +@click.argument( + "layout_descriptors", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) @click.option( - "--out", default="all", type=click.Path(), help="Keyboard drivers to generate." + "--out", + default="all", + type=click.Path(path_type=Path), + help="Keyboard drivers to generate.", ) -def make(layout_descriptors, out): +def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): """Convert TOML/YAML descriptions into OS-specific keyboard drivers.""" for input_file in layout_descriptors: @@ -95,37 +114,44 @@ def make(layout_descriptors, out): # quick output: reuse the input name and change the file extension if out in ["keylayout", "klc", "xkb", "xkb_custom", "svg"]: - output_file = os.path.splitext(input_file)[0] + "." + out + output_file = input_file.with_suffix("." + out) else: output_file = out # detailed output - if output_file.endswith(".ahk"): - with open(output_file, "w", encoding="utf-8", newline="\n") as file: + if output_file.suffix == ".ahk": + with output_file.open("w", encoding="utf-8", newline="\n") as file: file.write("\uFEFF") # AHK scripts require a BOM file.write(layout.ahk) - elif output_file.endswith(".klc"): - with open(output_file, "w", encoding="utf-16le", newline="\r\n") as file: - file.write(layout.klc) - elif output_file.endswith(".keylayout"): - with open(output_file, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.keylayout) - elif output_file.endswith(".xkb"): - with open(output_file, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.xkb) - elif output_file.endswith(".xkb_custom"): - with open(output_file, "w", encoding="utf-8", newline="\n") as file: - file.write(layout.xkb_patch) - elif output_file.endswith(".json"): + + elif output_file.suffix == ".klc": + output_file.write_text(layout.klc, "w", encoding="utf-16le", newline="\r\n") + + elif output_file.suffix == ".keylayout": + output_file.write_text( + layout.keylayout, "w", encoding="utf-8", newline="\n" + ) + + elif output_file.suffix == ".xkb": + output_file.write_text(layout.xkb, "w", encoding="utf-8", newline="\n") + + elif output_file.suffix == ".xkb_custom": + output_file.write_text( + layout.xkb_patch, "w", encoding="utf-8", newline="\n" + ) + + elif output_file.suffix == ".json": pretty_json(layout, output_file) - elif output_file.endswith(".svg"): + + elif output_file.suffix == ".svg": layout.svg.write(output_file, pretty_print=True, encoding="utf-8") + else: - print("Unsupported output format.") + click.echo("Unsupported output format.", err=True) return # successfully converted, display file name - print("... " + output_file) + click.echo("... " + output_file) TOML_HEADER = """# kalamine keyboard layout descriptor @@ -145,28 +171,32 @@ def make(layout_descriptors, out): 1dk_shift = "'" # apostrophe""" +# TODO: Provide geometry choices @cli.command() -@click.argument("output_file", nargs=1, type=click.Path(exists=False)) +@click.argument("output_file", nargs=1, type=click.Path(exists=False, path_type=Path)) @click.option("--geometry", default="ISO", help="Specify keyboard geometry.") @click.option("--altgr/--no-altgr", default=False, help="Set an AltGr layer.") @click.option("--1dk/--no-1dk", "odk", default=False, help="Set a custom dead key.") -def create(output_file, geometry, altgr, odk): +def create(output_file: Path, geometry: str, altgr: bool, odk: bool): """Create a new TOML layout description.""" + base_dir_path = Path(__file__).resolve(strict=True).parent.parent - root = Path(__file__).resolve(strict=True).parent.parent - - def get_layout(name): - layout = KeyboardLayout(str(root / "layouts" / f"{name}.toml")) + def get_layout(name: str) -> KeyboardLayout: + """Return a layout of type NAME with constrained geometry.""" + layout = KeyboardLayout(base_dir_path / "layouts" / f"{name}.toml") layout.geometry = geometry return layout - def keymap(layout_name, layout_layer, layer_name=""): - layer = "\n" - layer += f"\n{layer_name or layout_layer} = '''" - layer += "\n" - layer += "\n".join(getattr(get_layout(layout_name), layout_layer)) - layer += "\n'''" - return layer + def keymap(layout_name: str, layout_layer: str, layer_name: str = "") -> str: + return """ + +{} = ''' +{} +''' +""".format( + layer_name or layout_layer, + "\n".join(getattr(get_layout(layout_name), layout_layer)), + ) content = f'{TOML_HEADER}"{geometry.upper()}"' if odk: @@ -181,32 +211,29 @@ def keymap(layout_name, layout_layer, layer_name=""): content += keymap("ansi", "base") # append user guide sections - with (root / "docs" / "README.md").open() as f: + with (base_dir_path / "docs" / "README.md").open() as f: sections = "".join(f.readlines()).split("\n\n\n") for topic in sections[1:]: content += "\n\n" content += "\n# " content += "\n# ".join(topic.rstrip().split("\n")) - with open(output_file, "w", encoding="utf-8", newline="\n") as file: - file.write(content) - print("... " + output_file) + output_file.write_text(content, "w", encoding="utf-8", newline="\n") + click.echo(f"... {output_file}") @cli.command() -@click.argument("input", nargs=1, type=click.Path(exists=True)) -def watch(input): +@click.argument("filepath", nargs=1, type=click.Path(exists=True, path_type=Path)) +def watch(filepath: Path) -> None: """Watch a TOML/YAML layout description and display it in a web server.""" - - keyboard_server(input) + keyboard_server(filepath) @cli.command() -def version(): +def version() -> None: """Show version number and exit.""" - - print(f"kalamine { metadata.version('kalamine') }") + click.echo(f"kalamine { metadata.version('kalamine') }") if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/kalamine/cli_xkb.py b/kalamine/cli_xkb.py index adc292b..7a1ffaa 100644 --- a/kalamine/cli_xkb.py +++ b/kalamine/cli_xkb.py @@ -3,6 +3,8 @@ import platform import sys import tempfile +from pathlib import Path +from typing import List import click @@ -17,8 +19,10 @@ def cli(): @cli.command() -@click.argument("input", nargs=1, type=click.Path(exists=True)) -def apply(input): +@click.argument( + "filepath", type=click.Path(exists=True, dir_okay=False, path_type=Path) +) +def apply(filepath: Path): """Apply a Kalamine layout.""" if WAYLAND: @@ -26,20 +30,19 @@ def apply(input): "You appear to be running Wayland, which does not support this operation." ) - layout = KeyboardLayout(input) + layout = KeyboardLayout(filepath) with tempfile.NamedTemporaryFile( mode="w+", suffix=".xkb", encoding="utf-8" ) as temp_file: - try: - temp_file.write(layout.xkb) - os.system(f"xkbcomp -w0 {temp_file.name} $DISPLAY") - finally: - temp_file.close() + temp_file.write(layout.xkb) + os.system(f"xkbcomp -w0 {temp_file.name} $DISPLAY") @cli.command() -@click.argument("layouts", nargs=-1, type=click.Path(exists=True)) -def install(layouts): +@click.argument( + "layouts", nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path) +) +def install(layouts: List[Path]): """Install a list of Kalamine layouts.""" if not layouts: @@ -84,7 +87,7 @@ def xkb_install(xkb): @cli.command() @click.argument("mask") # [locale]/[name] -def remove(mask): +def remove(mask: str): """Remove a list of Kalamine layouts.""" def xkb_remove(root=False): @@ -102,17 +105,17 @@ def xkb_remove(root=False): xkb_remove() -@cli.command() +@cli.command(name="list") +@click.option("-a", "--all", "all_flag", is_flag=True) @click.argument("mask", default="*") -@click.option("--all", "-a", is_flag=True) -def list(mask, all): +def list_command(mask, all_flag): """List installed Kalamine layouts.""" for root in [True, False]: filtered = {} xkb = XKBManager(root=root) - layouts = xkb.list_all(mask) if all else xkb.list(mask) + layouts = xkb.list_all(mask) if all_flag else xkb.list(mask) for locale, variants in sorted(layouts.items()): for name, desc in sorted(variants.items()): filtered[f"{locale}/{name}"] = desc @@ -120,7 +123,12 @@ def list(mask, all): if mask == "*" and root and xkb.has_custom_symbols(): filtered["custom"] = "" - if bool(filtered): + if filtered: print(xkb.path) - for id, desc in filtered.items(): - print(f" {id:<24} {desc}") + for key, value in filtered.items(): + print(f" {key:<24} {value}") + + +if __name__ == "__main__": + cli() + cli() diff --git a/kalamine/layout.py b/kalamine/layout.py index f76d909..4815586 100644 --- a/kalamine/layout.py +++ b/kalamine/layout.py @@ -3,8 +3,10 @@ import os import re import sys -from typing import Any +from pathlib import Path +from typing import Dict, List, Union +import click import tomli import yaml from lxml import etree @@ -22,22 +24,14 @@ web_keymap, xkb_keymap, ) -from .utils import ( - DEAD_KEYS, - ODK_ID, - Layer, - lines_to_text, - load_data, - open_local_file, - text_to_lines, -) +from .utils import DEAD_KEYS, ODK_ID, Layer, lines_to_text, load_data, text_to_lines ### # Helpers # -def upper_key(letter): +def upper_key(letter: str) -> str: """This is used for presentation purposes: in a key, the upper character becomes blank if it's an obvious uppercase version of the base character.""" @@ -65,7 +59,7 @@ def upper_key(letter): return " " -def substitute_lines(text, variable, lines): +def substitute_lines(text: str, variable: str, lines: List[str]) -> str: prefix = "KALAMINE::" exp = re.compile(".*" + prefix + variable + ".*") @@ -79,18 +73,18 @@ def substitute_lines(text, variable, lines): return exp.sub(lines_to_text(lines, indent), text) -def substitute_token(text, token, value): +def substitute_token(text: str, token: str, value: str) -> str: exp = re.compile("\\$\\{" + token + "(=[^\\}]*){0,1}\\}") return exp.sub(value, text) -def load_tpl(layout, ext): +def load_tpl(layout: "KeyboardLayout", ext: str) -> str: tpl = "base" if layout.has_altgr: tpl = "full" if layout.has_1dk and ext.startswith(".xkb"): tpl = "full_1dk" - out = open_local_file(os.path.join("tpl", tpl + ext)).read() + out = (Path(__file__).parent / "tpl" / (tpl + ext)).read_text(encoding="utf-8") out = substitute_lines(out, "GEOMETRY_base", layout.base) out = substitute_lines(out, "GEOMETRY_full", layout.full) out = substitute_lines(out, "GEOMETRY_altgr", layout.altgr) @@ -99,11 +93,12 @@ def load_tpl(layout, ext): return out -def load_descriptor(file_path): - if file_path.endswith(".yaml") or file_path.endswith(".yml"): - with open(file_path, encoding="utf-8") as file: +def load_descriptor(file_path: Path) -> Dict: + if file_path.suffix in [".yaml", ".yml"]: + with file_path.open(encoding="utf-8") as file: return yaml.load(file, Loader=yaml.SafeLoader) - with open(file_path, mode="rb") as file: + + with file_path.open(mode="rb") as file: return tomli.load(file) @@ -137,7 +132,7 @@ def load_descriptor(file_path): class KeyboardLayout: """Lafayette-style keyboard layout: base + 1dk + altgr layers.""" - def __init__(self, filepath): + def __init__(self, filepath: Path) -> None: """Import a keyboard layout to instanciate the object.""" # initialize a blank layout @@ -152,13 +147,13 @@ def __init__(self, filepath): try: cfg = load_descriptor(filepath) if "extends" in cfg: - path = os.path.join(os.path.dirname(filepath), cfg["extends"]) + path = filepath.parent / cfg["extends"] ext = load_descriptor(path) ext.update(cfg) cfg = ext except Exception as exc: - print("File could not be parsed.") - print(f"Error: {exc}.") + click.echo("File could not be parsed.", err=True) + click.echo(f"Error: {exc}.", err=True) sys.exit(1) # metadata: self.meta @@ -170,7 +165,7 @@ def __init__(self, filepath): and not isinstance(cfg[k], dict) ): self.meta[k] = cfg[k] - filename = os.path.splitext(os.path.basename(filepath))[0] + filename = filepath.stem self.meta["name"] = cfg["name"] if "name" in cfg else filename self.meta["name8"] = cfg["name8"] if "name8" in cfg else self.meta["name"][0:8] self.meta["fileName"] = self.meta["name8"].lower() @@ -212,25 +207,25 @@ def __init__(self, filepath): self.dk_index.append(dk["char"]) # remove unused characters in self.dead_keys[].{base,alt} - def layer_has_char(char, layer_index): - for id in self.layers[layer_index]: - if self.layers[layer_index][id] == char: - return True - return False - - for dk_id in self.dead_keys: - base = self.dead_keys[dk_id]["base"] - alt = self.dead_keys[dk_id]["alt"] + def layer_has_char(char: str, layer_index: int) -> bool: + return any( + self.layers[layer_index][layer_id] == char + for layer_id in self.layers[layer_index] + ) + + for dk_dict in self.dead_keys.values(): + base = dk_dict["base"] + alt = dk_dict["alt"] used_base = "" used_alt = "" - for i in range(len(base)): - if layer_has_char(base[i], Layer.BASE) or layer_has_char( - base[i], Layer.SHIFT + for i, unknown_element in enumerate(base): + if layer_has_char(unknown_element, Layer.BASE) or layer_has_char( + unknown_element, Layer.SHIFT ): - used_base += base[i] + used_base += unknown_element used_alt += alt[i] - self.dead_keys[dk_id]["base"] = used_base - self.dead_keys[dk_id]["alt"] = used_alt + dk_dict["base"] = used_base + dk_dict["alt"] = used_alt # 1dk behavior if ODK_ID in self.dead_keys: @@ -250,7 +245,7 @@ def layer_has_char(char, layer_index): odk["base"] += base_char odk["alt"] += alt_char - def _parse_template(self, template, rows, layer_number): + def _parse_template(self, template: str, rows: List[str], layer_number: Layer): """Extract a keyboard layer from a template.""" if layer_number == Layer.BASE: @@ -296,7 +291,9 @@ def _parse_template(self, template, rows, layer_number): # Geometry: base, full, altgr # - def _fill_template(self, template, rows, layer_number): + def _fill_template( + self, template: str, rows: List[str], layer_number: Layer + ) -> str: """Fill a template with a keyboard layer.""" if layer_number == Layer.BASE: @@ -351,8 +348,10 @@ def _fill_template(self, template, rows, layer_number): return template - def _get_geometry(self, layers=[Layer.BASE]): + def _get_geometry(self, layers: Union[List[Layer], None] = None) -> str: """`geometry` view of the requested layers.""" + if layers is None: + layers = [Layer.BASE] rows = GEOMETRY[self.geometry]["rows"] template = GEOMETRY[self.geometry]["template"].split("\n")[:-1] @@ -361,12 +360,12 @@ def _get_geometry(self, layers=[Layer.BASE]): return template @property - def geometry(self): + def geometry(self) -> str: """ANSI, ISO, ERGO.""" return self.meta["geometry"].upper() @geometry.setter - def geometry(self, value): + def geometry(self, value: str) -> None: """ANSI, ISO, ERGO.""" shape = value.upper() if shape not in ["ANSI", "ISO", "ERGO"]: @@ -374,17 +373,17 @@ def geometry(self, value): self.meta["geometry"] = shape @property - def base(self): + def base(self) -> str: """Base + 1dk layers.""" return self._get_geometry([0, Layer.ODK]) @property - def full(self): + def full(self) -> str: """Base + AltGr layers.""" return self._get_geometry([0, Layer.ALTGR]) @property - def altgr(self): + def altgr(self) -> str: """AltGr layer only.""" return self._get_geometry([Layer.ALTGR]) @@ -393,7 +392,7 @@ def altgr(self): # @property - def keylayout(self): + def keylayout(self) -> str: """macOS driver""" out = load_tpl(self, ".keylayout") for i, layer in enumerate(osx_keymap(self)): @@ -403,7 +402,7 @@ def keylayout(self): return out @property - def ahk(self): + def ahk(self) -> str: """Windows AHK driver""" out = load_tpl(self, ".ahk") out = substitute_lines(out, "LAYOUT", ahk_keymap(self)) @@ -412,7 +411,7 @@ def ahk(self): return out @property - def klc(self): + def klc(self) -> str: """Windows driver (warning: requires CR/LF + UTF16LE encoding)""" out = load_tpl(self, ".klc") out = substitute_lines(out, "LAYOUT", klc_keymap(self)) @@ -422,14 +421,14 @@ def klc(self): return out @property - def xkb(self): # will not work with Wayland + def xkb(self) -> str: # will not work with Wayland """GNU/Linux driver (standalone / user-space)""" out = load_tpl(self, ".xkb") out = substitute_lines(out, "LAYOUT", xkb_keymap(self, xkbcomp=True)) return out @property - def xkb_patch(self): + def xkb_patch(self) -> str: """GNU/Linux driver (xkb patch, system or user-space)""" out = load_tpl(self, ".xkb_patch") out = substitute_lines(out, "LAYOUT", xkb_keymap(self, xkbcomp=False)) @@ -440,7 +439,7 @@ def xkb_patch(self): # @property - def json(self): + def json(self) -> Dict: """JSON layout descriptor""" return { "name": self.meta["name"], @@ -456,11 +455,11 @@ def json(self): # @property - def svg(self): + def svg(self) -> etree.ElementTree: """SVG drawing""" # Parse SVG data - filepath = os.path.join(os.path.dirname(__file__), "tpl", "x-keyboard.svg") - svg = etree.parse(filepath, etree.XMLParser(remove_blank_text=True)) + filepath = Path(__file__).parent / "tpl" / "x-keyboard.svg" + svg = etree.parse(str(filepath), etree.XMLParser(remove_blank_text=True)) ns = {"svg": "http://www.w3.org/2000/svg"} # Get Layout data diff --git a/kalamine/server.py b/kalamine/server.py index ee844cb..9d82a5f 100644 --- a/kalamine/server.py +++ b/kalamine/server.py @@ -4,20 +4,22 @@ import threading import webbrowser from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +import click from livereload import Server from .layout import KeyboardLayout -def keyboard_server(file_path): +def keyboard_server(file_path: Path) -> None: kb_layout = KeyboardLayout(file_path) host_name = "localhost" webserver_port = 1664 lr_server_port = 5500 - def main_page(layout): + def main_page(layout: KeyboardLayout) -> str: return f""" @@ -112,4 +114,4 @@ def send(page, content="text/plain", charset="utf-8"): webserver.shutdown() webserver.server_close() thread.join() - print("Server stopped.") + click.echo("Server stopped.") diff --git a/kalamine/template.py b/kalamine/template.py index 08f9bec..2d8618c 100644 --- a/kalamine/template.py +++ b/kalamine/template.py @@ -15,17 +15,17 @@ XKB_KEY_SYM = load_data("key_sym.yaml") -def hex_ord(char): +def hex_ord(char: str) -> str: return hex(ord(char))[2:].zfill(4) -def xml_proof(char): +def xml_proof(char: str) -> str: if char not in '<&"\u0020\u00a0\u202f>': return char return f"&#x{hex_ord(char)};" -def xml_proof_id(symbol): +def xml_proof_id(symbol: str) -> str: return symbol[2:-1] if symbol.startswith("&#x") else symbol @@ -36,7 +36,7 @@ def xml_proof_id(symbol): # -def xkb_keymap(layout, xkbcomp=False): +def xkb_keymap(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]: """Linux layout.""" show_description = True @@ -108,7 +108,7 @@ def xkb_keymap(layout, xkbcomp=False): # FWIW, PKL and EPKL still rely on AHK 1.1, too. -def ahk_keymap(layout, altgr=False): +def ahk_keymap(layout: "KeyboardLayout", altgr: bool = False) -> List[str]: """AHK layout, main and AltGr layers.""" prefixes = [" ", "+", "", "", " <^>!", "<^>!+"] @@ -169,7 +169,7 @@ def ahk_actions(symbol): return output -def ahk_shortcuts(layout): +def ahk_shortcuts(layout: "KeyboardLayout") -> List[str]: """AHK layout, shortcuts.""" prefixes = [" ^", "^+"] @@ -212,7 +212,7 @@ def ahk_shortcuts(layout): # -def klc_keymap(layout): +def klc_keymap(layout: "KeyboardLayout") -> List[str]: """Windows layout, main part.""" supported_symbols = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -283,7 +283,7 @@ def klc_keymap(layout): return output -def klc_deadkeys(layout): +def klc_deadkeys(layout: "KeyboardLayout") -> List[str]: """Windows layout, dead keys.""" output = [] @@ -312,7 +312,7 @@ def append_line(base, alt): return output[:-1] -def klc_dk_index(layout): +def klc_dk_index(layout: "KeyboardLayout") -> List[str]: """Windows layout, dead key index.""" output = [] @@ -322,7 +322,7 @@ def klc_dk_index(layout): return output -def klc_1dk(layout): +def klc_1dk(layout: "KeyboardLayout") -> List[str]: """Windows layout, 1dk.""" output = [] @@ -358,7 +358,7 @@ def klc_1dk(layout): # -def osx_keymap(layout): +def osx_keymap(layout: "KeyboardLayout") -> List[str]: """macOS layout, main part.""" ret_str = [] @@ -410,7 +410,7 @@ def has_dead_keys(letter): return ret_str -def osx_actions(layout): +def osx_actions(layout: "KeyboardLayout") -> List[str]: """macOS layout, dead key actions.""" ret_actions = [] @@ -487,7 +487,7 @@ def append_actions(symbol, actions): return ret_actions -def osx_terminators(layout): +def osx_terminators(layout: "KeyboardLayout") -> List[str]: """macOS layout, dead key terminators.""" ret_terminators = [] @@ -544,25 +544,25 @@ def web_deadkeys(layout: "KeyboardLayout") -> Dict[str, Dict[str, str]]: deadkeys = {} if layout.has_1dk: # ensure 1dk is first in the dead key dictionary deadkeys[ODK_ID] = {} - for id, dk in layout.dead_keys.items(): - deadkeys[id] = {} - deadkeys[id][id] = dk["alt_self"] - deadkeys[id]["\u0020"] = dk["alt_space"] - deadkeys[id]["\u00a0"] = dk["alt_space"] - deadkeys[id]["\u202f"] = dk["alt_space"] - if id == ODK_ID: + for index, dk in layout.dead_keys.items(): + deadkeys[index] = {} + deadkeys[index][index] = dk["alt_self"] + deadkeys[index]["\u0020"] = dk["alt_space"] + deadkeys[index]["\u00a0"] = dk["alt_space"] + deadkeys[index]["\u202f"] = dk["alt_space"] + if index == ODK_ID: for key_name in LAYER_KEYS: if key_name.startswith("-"): continue for i in [Layer.ODK_SHIFT, Layer.ODK]: if key_name in layout.layers[i]: - deadkeys[id][ + deadkeys[index][ layout.layers[i - Layer.ODK][key_name] ] = layout.layers[i][key_name] else: base = dk["base"] alt = dk["alt"] - for i in range(len(base)): - deadkeys[id][base[i]] = alt[i] + for i, unknown_element in enumerate(base): + deadkeys[index][unknown_element] = alt[i] return deadkeys diff --git a/kalamine/utils.py b/kalamine/utils.py index 3cc3430..f2b5ca3 100644 --- a/kalamine/utils.py +++ b/kalamine/utils.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 import os from enum import IntEnum +from pathlib import Path +from typing import Dict, List import yaml -def lines_to_text(lines, indent=""): +def lines_to_text(lines: List[str], indent: str = ""): out = "" for line in lines: if len(line): @@ -14,21 +16,18 @@ def lines_to_text(lines, indent=""): return out[:-1] -def text_to_lines(text): +def text_to_lines(text: str) -> List[str]: return text.split("\n") -def open_local_file(file_name): - return open(os.path.join(os.path.dirname(__file__), file_name), encoding="utf-8") - - -def load_data(filename): - return yaml.load( - open_local_file(os.path.join("data", filename)), Loader=yaml.SafeLoader - ) +def load_data(filename: str) -> Dict: + filepath = Path(__file__).parent / "data" / filename + return yaml.load(filepath.open(encoding="utf-8"), Loader=yaml.SafeLoader) class Layer(IntEnum): + """A layer designation.""" + BASE = 0 SHIFT = 1 ODK = 2 diff --git a/kalamine/xkb_manager.py b/kalamine/xkb_manager.py index 42be438..55ccd75 100644 --- a/kalamine/xkb_manager.py +++ b/kalamine/xkb_manager.py @@ -4,19 +4,23 @@ from os import environ from pathlib import Path from textwrap import dedent +from typing import Dict, ItemsView, Union +import click from lxml import etree from lxml.builder import E +from .layout import KeyboardLayout -def xdg_config_home(): + +def xdg_config_home() -> Path: xdg_config = environ.get("XDG_CONFIG_HOME") if xdg_config: return Path(xdg_config) return Path.home() / ".config" -def wayland_running(): +def wayland_running() -> bool: xdg_session = environ.get("XDG_SESSION_TYPE") if xdg_session: return xdg_session.startswith("wayland") @@ -32,20 +36,20 @@ def wayland_running(): class XKBManager: """Wrapper to list/add/remove keyboard drivers to XKB.""" - def __init__(self, root=False): + def __init__(self, root=False) -> None: self._as_root = root self._rootdir = XKB_ROOT if root else XKB_HOME self._index = {} @property - def index(self): + def index(self) -> ItemsView: return self._index.items() @property - def path(self): + def path(self) -> Path: return self._rootdir - def add(self, layout): + def add(self, layout: KeyboardLayout) -> None: locale = layout.meta["locale"] variant = layout.meta["variant"] if locale not in self._index: @@ -72,14 +76,14 @@ def clean(self): for variant in tree.xpath("//variant[@type]"): variant.attrib.pop("type") - def list(self, mask=""): + def list(self, mask: str = ""): layouts = list_rules(self._rootdir, mask) return list_symbols(self._rootdir, layouts) - def list_all(self, mask=""): + def list_all(self, mask: str = ""): return list_rules(self._rootdir, mask) - def has_custom_symbols(self): + def has_custom_symbols(self) -> str: """Check if there is a usable xkb/symbols/custom file.""" custom_path = self._rootdir / "symbols" / "custom" @@ -115,23 +119,21 @@ def ensure_xkb_config_is_ready(self): # xkb/rules/evdev rules = XKB_HOME / "rules" / ruleset if not rules.exists(): - with open(rules, "w") as rulesfile: - rulesfile.write( - dedent( - f""" + rules.write_text( + dedent( + f""" // Generated by Kalamine // Include the system '{ruleset}' file ! include %S/{ruleset} """ - ) ) + ) # xkb/rules/evdev.xml xmlpath = XKB_HOME / "rules" / f"{ruleset}.xml" if not xmlpath.exists(): - with open(xmlpath, "w") as xmlfile: - xmlfile.write( - dedent( - f"""\ + xmlpath.write_text( + dedent( + """\ @@ -139,8 +141,8 @@ def ensure_xkb_config_is_ready(self): """ - ) ) + ) """ On GNU/Linux, keyboard layouts must be installed in /usr/share/X11/xkb. To @@ -193,7 +195,7 @@ def ensure_xkb_config_is_ready(self): """ -def clean_legacy_lafayette(): +def clean_legacy_lafayette() -> None: return @@ -205,28 +207,29 @@ def clean_legacy_lafayette(): LEGACY_MARK = {"begin": "// LAFAYETTE::BEGIN\n", "end": "// LAFAYETTE::END\n"} -def get_symbol_mark(name): +def get_symbol_mark(name: str) -> Dict[str, str]: return { "begin": "// KALAMINE::" + name.upper() + "::BEGIN\n", "end": "// KALAMINE::" + name.upper() + "::END\n", } -def is_new_symbol_mark(line): - if line.endswith("::BEGIN\n"): - if line.startswith("// KALAMINE::"): - return line[13:-8].lower() # XXX Kalamine expects lowercase names - elif line.startswith("// LAFAYETTE::"): # obsolete marker - return "lafayette" - return None +def is_new_symbol_mark(line: str) -> Union[str, None]: + if not line.endswith("::BEGIN\n"): + return None + + if line.startswith("// KALAMINE::"): + return line[13:-8].lower() # XXX Kalamine expects lowercase names + + return "lafayette" # line.startswith("// LAFAYETTE::"): # obsolete marker -def update_symbols_locale(path, named_layouts): +def update_symbols_locale(path: Path, named_layouts: Dict) -> None: """Update Kalamine layouts in an xkb/symbols file.""" text = "" modified_text = False - with open(path, "r+", encoding="utf-8") as symbols: + with path.open("r+", encoding="utf-8") as symbols: # look for Kalamine layouts to be updated or removed between_marks = False closing_mark = "" @@ -271,25 +274,25 @@ def update_symbols_locale(path, named_layouts): symbols.close() -def update_symbols(xkb_root, kb_index): +def update_symbols(xkb_root: Path, kb_index: Dict) -> None: """Update Kalamine layouts in all xkb/symbols files.""" for locale, named_layouts in kb_index.items(): path = xkb_root / "symbols" / locale if not path.exists(): - with open(path, "w") as file: + with path.open("w") as file: file.write("// Generated by Kalamine") file.close() try: - print(f"... {path}") + click.echo(f"... {path}") update_symbols_locale(path, named_layouts) except Exception as exc: exit_FileNotWritable(exc, path) -def list_symbols(xkb_root, kb_index): +def list_symbols(xkb_root: Path, kb_index: Dict) -> Dict: """Filter input layouts: only keep the ones defined with Kalamine.""" filtered_index = {} @@ -314,7 +317,7 @@ def list_symbols(xkb_root, kb_index): # -def get_rules_locale(tree, locale): +def get_rules_locale(tree: etree.ElementTree, locale: Dict) -> str: query = f'//layout/configItem/name[text()="{locale}"]/../..' result = tree.xpath(query) if len(result) != 1: @@ -324,7 +327,7 @@ def get_rules_locale(tree, locale): return tree.xpath(query)[0] -def remove_rules_variant(variant_list, name): +def remove_rules_variant(variant_list, name: str) -> None: query = f'variant/configItem/name[text()="{name}"]/../..' for variant in variant_list.xpath(query): variant.getparent().remove(variant) @@ -336,7 +339,7 @@ def add_rules_variant(variant_list, name, description): ) -def update_rules(xkb_root, kb_index): +def update_rules(xkb_root: Path, kb_index: Dict) -> None: """Update references in XKB/rules/{base,evdev}.xml.""" for filename in ["base.xml", "evdev.xml"]: @@ -360,13 +363,13 @@ def update_rules(xkb_root, kb_index): tree.write( filepath, pretty_print=True, xml_declaration=True, encoding="utf-8" ) - print(f"... {filepath}") + click.echo(f"... {filepath}") except Exception as exc: exit_FileNotWritable(exc, filepath) -def list_rules(xkb_root, mask="*"): +def list_rules(xkb_root: Path, mask: str = "*") -> Dict: """List all matching XKB layouts.""" if mask in ("", "*"): @@ -408,9 +411,9 @@ def list_rules(xkb_root, mask="*"): def exit_FileNotWritable(exception, path): if isinstance(exception, PermissionError): # noqa: F821 raise exception - elif isinstance(exception, IOError): - print("") + if isinstance(exception, IOError): + click.echo("") sys.exit(f"Error: could not write to file {path}.") else: - print("") + click.echo("") sys.exit(f"Error: {exception}.\n{traceback.format_exc()}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_macos.py b/tests/test_macos.py index 01270a1..9270e4b 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -1,10 +1,10 @@ -import os +from pathlib import Path from lxml import etree -def check_keylayout(filename): - path = os.path.join(".", "dist", filename + ".keylayout") +def check_keylayout(filename: str): + path = Path(__file__).parent.parent / "dist" / (filename + ".keylayout") tree = etree.parse(path, etree.XMLParser(recover=True)) dead_keys = [] diff --git a/tests/test_parser.py b/tests/test_parser.py index 22eab6f..80f6402 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,10 +1,10 @@ -import os - from kalamine import KeyboardLayout +from .util import get_layout_path + def load_layout(filename): - return KeyboardLayout(os.path.join(".", "layouts", filename + ".toml")) + return KeyboardLayout(get_layout_path() / (filename + ".toml")) def test_layouts(): diff --git a/tests/test_serializer_ahk.py b/tests/test_serializer_ahk.py index c7df805..71dd19e 100644 --- a/tests/test_serializer_ahk.py +++ b/tests/test_serializer_ahk.py @@ -1,12 +1,13 @@ -import os from textwrap import dedent from kalamine import KeyboardLayout from kalamine.template import ahk_keymap, ahk_shortcuts +from .util import get_layout_path -def load_layout(filename): - return KeyboardLayout(os.path.join(".", "layouts", filename + ".toml")) + +def load_layout(filename) -> KeyboardLayout: + return KeyboardLayout(get_layout_path() / (filename + ".toml")) def split(multiline_str): diff --git a/tests/test_serializer_keylayout.py b/tests/test_serializer_keylayout.py index f44c1ee..1fd9e58 100644 --- a/tests/test_serializer_keylayout.py +++ b/tests/test_serializer_keylayout.py @@ -1,12 +1,13 @@ -import os from textwrap import dedent from kalamine import KeyboardLayout from kalamine.template import osx_actions, osx_keymap, osx_terminators +from .util import get_layout_path + def load_layout(filename): - return KeyboardLayout(os.path.join(".", "layouts", filename + ".toml")) + return KeyboardLayout(get_layout_path() / (filename + ".toml")) def split(multiline_str): diff --git a/tests/test_serializer_klc.py b/tests/test_serializer_klc.py index a91f625..25ef560 100644 --- a/tests/test_serializer_klc.py +++ b/tests/test_serializer_klc.py @@ -1,12 +1,13 @@ -import os from textwrap import dedent from kalamine import KeyboardLayout from kalamine.template import klc_deadkeys, klc_dk_index, klc_keymap +from .util import get_layout_path + def load_layout(filename): - return KeyboardLayout(os.path.join(".", "layouts", filename + ".toml")) + return KeyboardLayout(get_layout_path() / (filename + ".toml")) def split(multiline_str): diff --git a/tests/test_serializer_xkb.py b/tests/test_serializer_xkb.py index 5a1066a..704a259 100644 --- a/tests/test_serializer_xkb.py +++ b/tests/test_serializer_xkb.py @@ -1,12 +1,13 @@ -import os from textwrap import dedent from kalamine import KeyboardLayout from kalamine.template import xkb_keymap +from .util import get_layout_path + def load_layout(filename): - return KeyboardLayout(os.path.join(".", "layouts", filename + ".toml")) + return KeyboardLayout(get_layout_path() / (filename + ".toml")) def split(multiline_str): diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..bba0b50 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,7 @@ +"""Some util functions for tests.""" +from pathlib import Path + + +def get_layout_path() -> Path: + """Return the layout directory path.""" + return Path(__file__).parent.parent / "layouts" From 79bf0b06e3e31411adae17c1ee962468ff2daeec Mon Sep 17 00:00:00 2001 From: Etienne Monier Date: Wed, 31 Jan 2024 17:35:31 +0100 Subject: [PATCH 2/6] fix: Fix file write in CLI Newline argument was added to write_text in Python 3.10 --- kalamine/cli.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/kalamine/cli.py b/kalamine/cli.py index e291b91..93c767b 100644 --- a/kalamine/cli.py +++ b/kalamine/cli.py @@ -64,21 +64,23 @@ def file_creation_context(ext: str = "") -> Path: # Windows driver with file_creation_context(".klc") as klc_path: - klc_path.write_text(layout.klc, encoding="utf-16le", newline="\r\n") + with klc_path.open("w", encoding="utf-16le", newline="\r\n") as file: + file.write(layout.klc) # macOS driver with file_creation_context(".keylayout") as osx_path: - osx_path.write_text(layout.keylayout, encoding="utf-8", newline="\n") + with osx_path.open("w", encoding="utf-8", newline="\n") as file: + file.write(layout.keylayout) # Linux driver, user-space with file_creation_context(".xkb") as xkb_path: - xkb_path.write_text(layout.xkb, encoding="utf-8", newline="\n") + with xkb_path.open("w", encoding="utf-8", newline="\n") as file: + file.write(layout.xkb) # Linux driver, root with file_creation_context(".xkb_custom") as xkb_custom_path: - xkb_custom_path.write_text( - layout.xkb_patch, "w", encoding="utf-8", newline="\n" - ) + with xkb_custom_path.open("w", encoding="utf-8", newline="\n") as file: + file.write(layout.xkb_patch) # JSON data with file_creation_context(".json") as json_path: @@ -98,7 +100,7 @@ def file_creation_context(ext: str = "") -> Path: @click.option( "--out", default="all", - type=click.Path(path_type=Path), + type=click.Path(), help="Keyboard drivers to generate.", ) def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): @@ -109,9 +111,12 @@ def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): # default: build all in the `dist` subdirectory if out == "all": - make_all(layout, "dist") + make_all(layout, Path(__file__).parent.parent / "dist") continue + # Transform out into Path. + out = Path(out) + # quick output: reuse the input name and change the file extension if out in ["keylayout", "klc", "xkb", "xkb_custom", "svg"]: output_file = input_file.with_suffix("." + out) @@ -125,20 +130,24 @@ def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): file.write(layout.ahk) elif output_file.suffix == ".klc": - output_file.write_text(layout.klc, "w", encoding="utf-16le", newline="\r\n") + with output_file.open("w", encoding="utf-16le", newline="\r\n") as file: + file.write(layout.klc) elif output_file.suffix == ".keylayout": - output_file.write_text( - layout.keylayout, "w", encoding="utf-8", newline="\n" - ) + with output_file.open( + "w", encoding="utf-8", newline="\n" + ) as file: + file.write(layout.keylayout) elif output_file.suffix == ".xkb": - output_file.write_text(layout.xkb, "w", encoding="utf-8", newline="\n") + with output_file.open("w", encoding="utf-8", newline="\n") as file: + file.write(layout.xkb) elif output_file.suffix == ".xkb_custom": - output_file.write_text( - layout.xkb_patch, "w", encoding="utf-8", newline="\n" - ) + with output_file.open( + "w", encoding="utf-8", newline="\n" + ) as file: + file.write(layout.xkb_patch) elif output_file.suffix == ".json": pretty_json(layout, output_file) @@ -151,7 +160,7 @@ def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): return # successfully converted, display file name - click.echo("... " + output_file) + click.echo(f"... {output_file}") TOML_HEADER = """# kalamine keyboard layout descriptor From 35a3772977b8c5df1cb5e29f189f98471cd3b2a3 Mon Sep 17 00:00:00 2001 From: Fabien Cazenave Date: Thu, 1 Feb 2024 16:26:15 +0100 Subject: [PATCH 3/6] Update layout.py fixing a typo I did while rebasing in the web interface --- kalamine/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalamine/layout.py b/kalamine/layout.py index 9fff718..00efca9 100644 --- a/kalamine/layout.py +++ b/kalamine/layout.py @@ -209,7 +209,7 @@ def __init__(self, filepath: Path) -> None: def _parse_dead_keys(self, spc): """Build a deadkey dict.""" - def (char: str) -> bool: + def layout_has_char(char: str) -> bool: all_layers = [Layer.BASE, Layer.SHIFT] if self.has_altgr: all_layers += [Layer.ALTGR, Layer.ALTGR_SHIFT] From 6d7cb47c970f565c0e33d91cec551c763eb84882 Mon Sep 17 00:00:00 2001 From: Fabien Cazenave Date: Thu, 1 Feb 2024 16:29:08 +0100 Subject: [PATCH 4/6] Update cli_xkb.py fix a quick typo --- kalamine/cli_xkb.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kalamine/cli_xkb.py b/kalamine/cli_xkb.py index 7a1ffaa..d985fcb 100644 --- a/kalamine/cli_xkb.py +++ b/kalamine/cli_xkb.py @@ -131,4 +131,3 @@ def list_command(mask, all_flag): if __name__ == "__main__": cli() - cli() From affc8ae01b2b6c2bd4d642d531bd6fde009b04d4 Mon Sep 17 00:00:00 2001 From: Fabien Cazenave Date: Thu, 1 Feb 2024 16:30:00 +0100 Subject: [PATCH 5/6] Update cli.py str/Path mismatch --- kalamine/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalamine/cli.py b/kalamine/cli.py index 1c40bf9..839b367 100644 --- a/kalamine/cli.py +++ b/kalamine/cli.py @@ -111,7 +111,7 @@ def make(layout_descriptors: List[Path], out: Union[Path, Literal["all"]]): # default: build all in the `dist` subdirectory if out == "all": - make_all(layout, "dist") + make_all(layout, Path("dist")) continue # Transform out into Path. From 805e902cc6015d9e23549902722f5b098743b0e7 Mon Sep 17 00:00:00 2001 From: Fabien Cazenave Date: Thu, 1 Feb 2024 16:35:02 +0100 Subject: [PATCH 6/6] Update test_serializer_klc.py fix a rebasing mistake --- tests/test_serializer_klc.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_serializer_klc.py b/tests/test_serializer_klc.py index 0904dab..b7423bc 100644 --- a/tests/test_serializer_klc.py +++ b/tests/test_serializer_klc.py @@ -8,17 +8,13 @@ from .util import get_layout_path -def load_layout(filename): - return KeyboardLayout(get_layout_path() / (filename + ".toml")) - - def split(multiline_str): return dedent(multiline_str).lstrip().rstrip().splitlines() LAYOUTS = {} -for id in ["ansi", "intl", "prog"]: - LAYOUTS[id] = KeyboardLayout(os.path.join(".", "layouts", id + ".toml")) +for filename in ["ansi", "intl", "prog"]: + LAYOUTS[filename] = KeyboardLayout(get_layout_path() / (filename + ".toml")) def test_ansi_keymap():