From: Jörg Behrmann Date: Mon, 15 Jul 2024 17:55:58 +0000 (+0200) Subject: completion: make it a verb and factor it out into a separate file X-Git-Tag: v24~7^2~4 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=795272f2bab52bb81c3f633bb6cc783f7e7d6454;p=thirdparty%2Fmkosi.git completion: make it a verb and factor it out into a separate file --- diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 9146bd6a8..e15df09ff 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -27,6 +27,7 @@ from typing import Optional, Union, cast from mkosi.archive import can_extract_tar, extract_tar, make_cpio, make_tar from mkosi.burn import run_burn +from mkosi.completion import print_completion from mkosi.config import ( PACKAGE_GLOBS, Args, @@ -43,15 +44,10 @@ from mkosi.config import ( Network, OutputFormat, SecureBootSignTool, - ShellCompletion, ShimBootloader, Verb, Vmm, __version__, - collect_completion_arguments, - finalize_completion_bash, - finalize_completion_fish, - finalize_completion_zsh, format_bytes, parse_config, summary, @@ -4420,16 +4416,6 @@ def show_docs(args: Args, *, resources: Path) -> None: raise e -def print_completion(args: Args, *, resources: Path) -> None: - completion_args = collect_completion_arguments() - if args.shell_completion == ShellCompletion.bash: - print(finalize_completion_bash(completion_args, resources)) - elif args.shell_completion == ShellCompletion.fish: - print(finalize_completion_fish(completion_args, resources)) - elif args.shell_completion == ShellCompletion.zsh: - print(finalize_completion_zsh(completion_args, resources)) - - def expand_specifier(s: str) -> str: return s.replace("%u", INVOKING_USER.name()) @@ -4770,7 +4756,7 @@ def run_verb(args: Args, images: Sequence[Config], *, resources: Path) -> None: if args.verb.needs_root() and os.getuid() != 0: die(f"Must be root to run the {args.verb} command") - if args.shell_completion: + if args.verb == Verb.completion: return print_completion(args, resources=resources) if args.verb == Verb.documentation: diff --git a/mkosi/completion.py b/mkosi/completion.py new file mode 100644 index 000000000..bdaa42d52 --- /dev/null +++ b/mkosi/completion.py @@ -0,0 +1,201 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +import argparse +import dataclasses +import io +import shlex +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Optional, Union + +from mkosi.config import SETTINGS, SETTINGS_LOOKUP_BY_DEST, Args, CompGen, Verb, create_argument_parser +from mkosi.log import die + + +@dataclasses.dataclass(frozen=True) +class CompletionItem: + short: Optional[str] + long: Optional[str] + help: Optional[str] + nargs: Union[str, int] + choices: list[str] + compgen: CompGen + + +def collect_completion_arguments() -> list[CompletionItem]: + parser = create_argument_parser() + + options = [ + CompletionItem( + short=next((s for s in action.option_strings if not s.startswith("--")), None), + long=next((s for s in action.option_strings if s.startswith("--")), None), + help=action.help, + nargs=action.nargs or 0, + choices=[str(c) for c in action.choices] if action.choices is not None else [], + compgen=CompGen.from_action(action), + ) + for action in parser._actions + if (action.option_strings and + action.help != argparse.SUPPRESS and + action.dest not in SETTINGS_LOOKUP_BY_DEST) + ] + + options += [ + CompletionItem( + short=setting.short, + long=setting.long, + help=setting.help, + nargs=setting.nargs or 1, + choices=[str(c) for c in setting.choices] if setting.choices is not None else [], + compgen=setting.compgen, + ) + for setting in SETTINGS + ] + + return options + + +def finalize_completion_bash(options: list[CompletionItem], resources: Path) -> str: + def to_bash_array(name: str, entries: Iterable[str]) -> str: + return f"declare -a {name.replace('-', '_')}=(" + " ".join(shlex.quote(str(e)) for e in entries) + ")" + + def to_bash_hasharray(name: str, entries: Mapping[str, Union[str, int]]) -> str: + return ( + f"declare -A {name.replace('-', '_')}=(" + + " ".join(f"[{shlex.quote(str(k))}]={shlex.quote(str(v))}" for k, v in entries.items()) + ")" + ) + + completion = resources / "completion.bash" + + options_by_key = {o.short: o for o in options if o.short} | {o.long: o for o in options if o.long} + + with io.StringIO() as c: + c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") + c.write(to_bash_array("_mkosi_options", options_by_key.keys())) + c.write("\n\n") + + nargs = to_bash_hasharray("_mkosi_nargs", {optname: v.nargs for optname, v in options_by_key.items()}) + c.write(nargs) + c.write("\n\n") + + choices = to_bash_hasharray( + "_mkosi_choices", {optname: " ".join(v.choices) for optname, v in options_by_key.items() if v.choices} + ) + c.write(choices) + c.write("\n\n") + + compgen = to_bash_hasharray( + "_mkosi_compgen", + {optname: v.compgen.to_bash() for optname, v in options_by_key.items() if v.compgen != CompGen.default}, + ) + c.write(compgen) + c.write("\n\n") + + c.write(to_bash_array("_mkosi_verbs", [str(v) for v in Verb])) + c.write("\n\n\n") + + c.write(completion.read_text()) + + return c.getvalue() + + +def finalize_completion_fish(options: list[CompletionItem], resources: Path) -> str: + with io.StringIO() as c: + c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") + c.write("complete -c mkosi -f\n") + + c.write("complete -c mkosi -n '__fish_is_first_token' -a \"") + c.write(" ".join(str(v) for v in Verb)) + c.write("\"\n") + + for option in options: + if not option.short and not option.long: + continue + + c.write("complete -c mkosi ") + if option.short: + c.write(f"-s {option.short.lstrip('-')} ") + if option.long: + c.write(f"-l {option.long.lstrip('-')} ") + if isinstance(option.nargs, int) and option.nargs > 0: + c.write("-r ") + if option.choices: + c.write("-a \"") + c.write(" ".join(option.choices)) + c.write("\" ") + if option.help is not None: + help = option.help.replace("'", "\\'") + c.write(f"-d \"{help}\" ") + c.write(option.compgen.to_fish()) + c.write("\n") + return c.getvalue() + + +def finalize_completion_zsh(options: list[CompletionItem], resources: Path) -> str: + def to_zsh_array(name: str, entries: Iterable[str]) -> str: + return f"declare -a {name.replace('-', '_')}=(" + " ".join(shlex.quote(str(e)) for e in entries) + ")" + + completion = resources / "completion.zsh" + + with io.StringIO() as c: + c.write("#compdef mkosi\n") + c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") + + c.write(to_zsh_array("_mkosi_verbs", [str(v) for v in Verb])) + c.write("\n\n") + + + c.write(completion.read_text()) + c.write("\n") + + c.write("_arguments -s \\\n") + c.write(" '(- *)'{-h,--help}'[Show this help]' \\\n") + c.write(" '(- *)--version[Show package version]' \\\n") + + for option in options: + if not option.short and not option.long: + continue + + posix = option.help and "'" in option.help + open_quote = "$'" if posix else "'" + if option.short and option.long: + c.write(f" '({option.short} {option.long})'{{{option.short},{option.long}}}{open_quote}") + else: + c.write(f" {open_quote}{option.short or option.long}") + + if option.help: + help = option.help.replace("'", r"\'") + c.write(f"[{help}]") + if option.choices: + # TODO: maybe use metavar here? At least for me it's not shown, though + c.write(":arg:(") + c.write(" ".join(option.choices)) + c.write(")") + c.write(option.compgen.to_zsh()) + c.write("' \\\n") + + c.write(" '*::mkosi verb:_mkosi_verb'\n\n") + + return c.getvalue() + + +def print_completion(args: Args, *, resources: Path) -> None: + if not args.cmdline: + die( + "No shell to generate completion script for specified", + hint="Please specify either one of: bash, fish, zsh" + ) + + shell = args.cmdline[0] + completion_args = collect_completion_arguments() + if shell == "bash": + print(finalize_completion_bash(completion_args, resources)) + elif shell == "fish": + print(finalize_completion_fish(completion_args, resources)) + elif shell == "zsh": + print(finalize_completion_zsh(completion_args, resources)) + else: + die( + f"{shell!r} is not supported for completion scripts.", + hint="Please specify either one of: bash, fish, zsh" + ) diff --git a/mkosi/config.py b/mkosi/config.py index a8b169554..e02626015 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -10,7 +10,6 @@ import fnmatch import functools import graphlib import inspect -import io import json import logging import math @@ -27,7 +26,7 @@ import tempfile import textwrap import typing import uuid -from collections.abc import Collection, Iterable, Iterator, Mapping, Sequence +from collections.abc import Collection, Iterable, Iterator, Sequence from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Callable, Optional, TypeVar, Union, cast @@ -76,6 +75,7 @@ class Verb(StrEnum): coredumpctl = enum.auto() burn = enum.auto() dependencies = enum.auto() + completion = enum.auto() def supports_cmdline(self) -> bool: return self in ( @@ -87,6 +87,7 @@ class Verb(StrEnum): Verb.journalctl, Verb.coredumpctl, Verb.burn, + Verb.completion, ) def needs_build(self) -> bool: @@ -240,16 +241,6 @@ class DocFormat(StrEnum): system = enum.auto() -class ShellCompletion(StrEnum): - none = enum.auto() - bash = enum.auto() - fish = enum.auto() - zsh = enum.auto() - - def __bool__(self) -> bool: - return self != ShellCompletion.none - - class Bootloader(StrEnum): none = enum.auto() uki = enum.auto() @@ -484,6 +475,49 @@ class Architecture(StrEnum): return cls.from_uname(platform.machine()) +class CompGen(StrEnum): + default = enum.auto() + files = enum.auto() + dirs = enum.auto() + + @staticmethod + def from_action(action: argparse.Action) -> "CompGen": + if isinstance(action.default, Path): + if action.default.is_dir(): + return CompGen.dirs + else: + return CompGen.files + # TODO: the type of action.type is Union[Callable[[str], Any], FileType] + # the type of Path is type, but Path also works in this position, + # because the constructor is a callable from str -> Path + elif action.type is not None and (isinstance(action.type, type) and issubclass(action.type, Path)): # type: ignore + if isinstance(action.default, Path) and action.default.is_dir(): # type: ignore + return CompGen.dirs + else: + return CompGen.files + + return CompGen.default + + def to_bash(self) -> str: + return f"_mkosi_compgen_{self}" + + def to_fish(self) -> str: + if self == CompGen.files: + return "--force-files" + elif self == CompGen.dirs: + return "--force-files -a '(__fish_complete_directories)'" + else: + return "-f" + + def to_zsh(self) -> str: + if self == CompGen.files: + return ":path:_files -/" + elif self == CompGen.dirs: + return ":directory:_files -f" + else: + return "" + + def parse_boolean(s: str) -> bool: "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false" @@ -1172,49 +1206,6 @@ def config_parse_key_source(value: Optional[str], old: Optional[KeySource]) -> O return KeySource(type=type, source=source) -class CompGen(StrEnum): - default = enum.auto() - files = enum.auto() - dirs = enum.auto() - - @staticmethod - def from_action(action: argparse.Action) -> "CompGen": - if isinstance(action.default, Path): - if action.default.is_dir(): - return CompGen.dirs - else: - return CompGen.files - # TODO: the type of action.type is Union[Callable[[str], Any], FileType] - # the type of Path is type, but Path also works in this position, - # because the constructor is a callable from str -> Path - elif action.type is not None and (isinstance(action.type, type) and issubclass(action.type, Path)): # type: ignore - if isinstance(action.default, Path) and action.default.is_dir(): # type: ignore - return CompGen.dirs - else: - return CompGen.files - - return CompGen.default - - def to_bash(self) -> str: - return f"_mkosi_compgen_{self}" - - def to_fish(self) -> str: - if self == CompGen.files: - return "--force-files" - elif self == CompGen.dirs: - return "--force-files -a '(__fish_complete_directories)'" - else: - return "-f" - - def to_zsh(self) -> str: - if self == CompGen.files: - return ":path:_files -/" - elif self == CompGen.dirs: - return ":directory:_files -f" - else: - return "" - - @dataclasses.dataclass(frozen=True) class ConfigSetting: dest: str @@ -1361,7 +1352,6 @@ class Args: auto_bump: bool doc_format: DocFormat json: bool - shell_completion: ShellCompletion @classmethod def default(cls) -> "Args": @@ -3328,13 +3318,6 @@ def create_argument_parser(chdir: bool = True) -> argparse.ArgumentParser: type=DocFormat, choices=list(DocFormat), ) - parser.add_argument( - "--shell-completion", - help="The shell to print a completion script for", - type=ShellCompletion, - default=ShellCompletion.none, - choices=list(ShellCompletion), - ) parser.add_argument( "--json", help="Show summary as JSON", @@ -4418,7 +4401,6 @@ def json_type_transformer(refcls: Union[type[Args], type[Config]]) -> Callable[[ Network: enum_transformer, KeySource: key_source_transformer, Vmm: enum_transformer, - ShellCompletion: enum_transformer, } def json_transformer(key: str, val: Any) -> Any: @@ -4493,170 +4475,3 @@ def systemd_tool_version(config: Config, *tool: PathString) -> GenericVersion: sandbox=config.sandbox(binary=tool[-1]), ).stdout.split()[2].strip("()").removeprefix("v") ) - - -@dataclasses.dataclass(frozen=True) -class CompletionItem: - short: Optional[str] - long: Optional[str] - help: Optional[str] - nargs: Union[str, int] - choices: list[str] - compgen: CompGen - - -def collect_completion_arguments() -> list[CompletionItem]: - parser = create_argument_parser() - - options = [ - CompletionItem( - short=next((s for s in action.option_strings if not s.startswith("--")), None), - long=next((s for s in action.option_strings if s.startswith("--")), None), - help=action.help, - nargs=action.nargs or 0, - choices=[str(c) for c in action.choices] if action.choices is not None else [], - compgen=CompGen.from_action(action), - ) - for action in parser._actions - if (action.option_strings and - action.help != argparse.SUPPRESS and - action.dest not in SETTINGS_LOOKUP_BY_DEST) - ] - - options += [ - CompletionItem( - short=setting.short, - long=setting.long, - help=setting.help, - nargs=setting.nargs or 1, - choices=[str(c) for c in setting.choices] if setting.choices is not None else [], - compgen=setting.compgen, - ) - for setting in SETTINGS - ] - - return options - - -def finalize_completion_bash(options: list[CompletionItem], resources: Path) -> str: - def to_bash_array(name: str, entries: Iterable[str]) -> str: - return f"declare -a {name.replace('-', '_')}=(" + " ".join(shlex.quote(str(e)) for e in entries) + ")" - - def to_bash_hasharray(name: str, entries: Mapping[str, Union[str, int]]) -> str: - return ( - f"declare -A {name.replace('-', '_')}=(" + - " ".join(f"[{shlex.quote(str(k))}]={shlex.quote(str(v))}" for k, v in entries.items()) + ")" - ) - - completion = resources / "completion.bash" - - options_by_key = {o.short: o for o in options if o.short} | {o.long: o for o in options if o.long} - - with io.StringIO() as c: - c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") - c.write(to_bash_array("_mkosi_options", options_by_key.keys())) - c.write("\n\n") - - nargs = to_bash_hasharray("_mkosi_nargs", {optname: v.nargs for optname, v in options_by_key.items()}) - c.write(nargs) - c.write("\n\n") - - choices = to_bash_hasharray( - "_mkosi_choices", {optname: " ".join(v.choices) for optname, v in options_by_key.items() if v.choices} - ) - c.write(choices) - c.write("\n\n") - - compgen = to_bash_hasharray( - "_mkosi_compgen", - {optname: v.compgen.to_bash() for optname, v in options_by_key.items() if v.compgen != CompGen.default}, - ) - c.write(compgen) - c.write("\n\n") - - c.write(to_bash_array("_mkosi_verbs", [str(v) for v in Verb])) - c.write("\n\n\n") - - c.write(completion.read_text()) - - return c.getvalue() - - -def finalize_completion_fish(options: list[CompletionItem], resources: Path) -> str: - with io.StringIO() as c: - c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") - c.write("complete -c mkosi -f\n") - - c.write("complete -c mkosi -n '__fish_is_first_token' -a \"") - c.write(" ".join(str(v) for v in Verb)) - c.write("\"\n") - - for option in options: - if not option.short and not option.long: - continue - - c.write("complete -c mkosi ") - if option.short: - c.write(f"-s {option.short.lstrip('-')} ") - if option.long: - c.write(f"-l {option.long.lstrip('-')} ") - if isinstance(option.nargs, int) and option.nargs > 0: - c.write("-r ") - if option.choices: - c.write("-a \"") - c.write(" ".join(option.choices)) - c.write("\" ") - if option.help is not None: - help = option.help.replace("'", "\\'") - c.write(f"-d \"{help}\" ") - c.write(option.compgen.to_fish()) - c.write("\n") - return c.getvalue() - - -def finalize_completion_zsh(options: list[CompletionItem], resources: Path) -> str: - def to_zsh_array(name: str, entries: Iterable[str]) -> str: - return f"declare -a {name.replace('-', '_')}=(" + " ".join(shlex.quote(str(e)) for e in entries) + ")" - - completion = resources / "completion.zsh" - - with io.StringIO() as c: - c.write("#compdef mkosi\n") - c.write("# SPDX-License-Identifier: LGPL-2.1-or-later\n\n") - - c.write(to_zsh_array("_mkosi_verbs", [str(v) for v in Verb])) - c.write("\n\n") - - - c.write(completion.read_text()) - c.write("\n") - - c.write("_arguments -s \\\n") - c.write(" '(- *)'{-h,--help}'[Show this help]' \\\n") - c.write(" '(- *)--version[Show package version]' \\\n") - - for option in options: - if not option.short and not option.long: - continue - - posix = option.help and "'" in option.help - open_quote = "$'" if posix else "'" - if option.short and option.long: - c.write(f" '({option.short} {option.long})'{{{option.short},{option.long}}}{open_quote}") - else: - c.write(f" {open_quote}{option.short or option.long}") - - if option.help: - help = option.help.replace("'", r"\'") - c.write(f"[{help}]") - if option.choices: - # TODO: maybe use metavar here? At least for me it's not shown, though - c.write(":arg:(") - c.write(" ".join(option.choices)) - c.write(")") - c.write(option.compgen.to_zsh()) - c.write("' \\\n") - - c.write(" '*::mkosi verb:_mkosi_verb'\n\n") - - return c.getvalue() diff --git a/tests/test_json.py b/tests/test_json.py index e3fb0d857..43a51f1ab 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -27,7 +27,6 @@ from mkosi.config import ( QemuFirmware, QemuVsockCID, SecureBootSignTool, - ShellCompletion, ShimBootloader, Verb, Vmm, @@ -56,7 +55,6 @@ def test_args(path: Optional[Path]) -> None: "GenkeyValidDays": "100", "Json": false, "Pager": true, - "ShellCompletion": "none", "Verb": "build" }} """ @@ -75,7 +73,6 @@ def test_args(path: Optional[Path]) -> None: genkey_valid_days = "100", json = False, pager = True, - shell_completion = ShellCompletion.none, verb = Verb.build, )