]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
completion: make it a verb and factor it out into a separate file
authorJörg Behrmann <behrmann@physik.fu-berlin.de>
Mon, 15 Jul 2024 17:55:58 +0000 (19:55 +0200)
committerZbigniew Jędrzejewski-Szmek <zbyszek@in.waw.pl>
Mon, 22 Jul 2024 09:16:45 +0000 (11:16 +0200)
mkosi/__init__.py
mkosi/completion.py [new file with mode: 0644]
mkosi/config.py
tests/test_json.py

index 9146bd6a8c7522c1f12d807fd36464352a137a4e..e15df09ff970876d35c969e2c0d6dd951ab86278 100644 (file)
@@ -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 (file)
index 0000000..bdaa42d
--- /dev/null
@@ -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"
+        )
index a8b1695548712282b7f5d923ea473156f594c991..e026260151d5b6dfd6f010a41154499b9e62bde5 100644 (file)
@@ -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()
index e3fb0d857e66ce43a099f878631fdba5125c8e3e..43a51f1ab121f2966206307fb012d752209bdffe 100644 (file)
@@ -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,
     )