| #!/usr/bin/env fuchsia-vendored-python |
| |
| # Copyright 2024 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Generates a bash completion script for `ffx` tool. |
| |
| Usage to generate from ffx output: |
| source <(python3 scripts/ffx_complete/ffx_gen_complete.py --ffx_help_json_file_path=<(fx ffx --machine json --help)) |
| |
| Usage to generate from ffx golden files: |
| source <(python3 scripts/ffx_complete/ffx_gen_complete.py --ffx_golden_dir_path=src/developer/ffx/tests/cli-goldens/goldens) |
| """ |
| import argparse |
| import dataclasses |
| import json |
| import os |
| import pathlib |
| import sys |
| import textwrap |
| import typing |
| from typing import Any, Iterable, Sequence, TypeAlias |
| |
| JSONObject: TypeAlias = dict[str, "JSONValue"] |
| JSONValue: TypeAlias = JSONObject | str | int | float | list["JSONValue"] |
| |
| try: |
| import dataclasses_json_lite |
| except ImportError: |
| # Make possible to run out of `fx` command. |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../memory")) |
| import dataclasses_json_lite |
| |
| |
| ARG_NAME_TO_EXPR = { |
| ("Ffx", "target"): "$(ffx target list -f n)", |
| ("Ffx", "machine"): "json json-pretty", |
| ("Ffx", "log-level"): "Info Error Warn Trace", |
| } |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class Option: |
| arg_name: str |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class Kind: |
| option: Option | None = dataclasses.field( |
| metadata=dataclasses_json_lite.config(field_name="Option"), default=None |
| ) |
| text: str | None = None |
| |
| |
| def kind_or_string(json_data: dict[str, Any] | str) -> Kind | str: |
| if isinstance(json_data, dict): |
| return Kind.from_dict(json_data) # type: ignore[attr-defined] |
| elif isinstance(json_data, str): |
| return Kind(text=json_data) |
| else: |
| raise ValueError( |
| f"Kind should be a string or a dict but was {json_data!r}" |
| ) |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class Flag: |
| kind: Kind | str = dataclasses.field( |
| metadata=dataclasses_json_lite.config(decoder=kind_or_string) |
| ) |
| optionality: str |
| long: str |
| short: str | None |
| description: str |
| hidden: bool |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class Positional: |
| name: str |
| description: str |
| optionality: str |
| hidden: bool |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class ErrorCode: |
| code: int |
| description: str |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class Command: |
| name: str |
| description: str |
| examples: Sequence[str] |
| flags: Sequence[Flag] |
| commands: Sequence["SubCommand"] |
| positionals: Sequence[Positional] |
| notes: Sequence[str] | None |
| error_codes: Sequence[ErrorCode] | None |
| |
| |
| @dataclasses_json_lite.dataclass_json() |
| @dataclasses.dataclass |
| class SubCommand: |
| name: str |
| command: Command |
| |
| |
| def print_flag( |
| command: Command, flag: Flag, indent: int, file: typing.TextIO |
| ) -> None: |
| def out(text: str) -> None: |
| print(textwrap.indent(text, " " * indent), file=file) |
| |
| out(f"{flag.long})") |
| if isinstance(flag.kind, Kind) and flag.kind.option: |
| key = (command.name, flag.kind.option.arg_name) |
| if key in ARG_NAME_TO_EXPR: |
| out(f" if (( word_index + 1 == COMP_CWORD)); then") |
| out( |
| f""" COMPREPLY=($(compgen -W "{ARG_NAME_TO_EXPR[key]}" -- "${{COMP_WORDS[$COMP_CWORD]}}"))""" |
| ) |
| out(f" return") |
| out(f" fi") |
| |
| out(f" ((word_index+=2))") |
| out(f" ;;") |
| |
| |
| def print_command(command: Command, indent: int, file: typing.TextIO) -> None: |
| def out(text: str) -> None: |
| print(textwrap.indent(text, " " * indent), file=file) |
| |
| out("""while [ "$word_index" -lt "$COMP_CWORD" ]; do""") |
| out(""" case "${COMP_WORDS[$word_index]}" in""") |
| for flag in command.flags: |
| print_flag(command, flag, indent + 4, file) |
| |
| for sub_cmd in command.commands: |
| out(f" {sub_cmd.name})") |
| out(f" ((word_index++))") |
| print_command(sub_cmd.command, indent + 6, file) |
| out(f" ;;") |
| |
| out(" *)") |
| out(" ((word_index++))") |
| out(" esac") |
| out(" done") |
| words = [] |
| words += [flag.long for flag in command.flags] |
| words += [f"-{flag.short}" for flag in command.flags if flag.short] |
| words += [f"{sub_cmd.name}" for sub_cmd in command.commands] |
| out( |
| f"""COMPREPLY=($(compgen -W "{' '.join(words)}" -- "${{COMP_WORDS[$COMP_CWORD]}}"))""" |
| ) |
| out("""return""") |
| |
| |
| def print_script(root_command: Command, file: typing.TextIO) -> None: |
| print("#/usr/bin/env bash", file=file) |
| print("_ffx_completions() {", file=file) |
| print("word_index=1", file=file) |
| print_command(root_command, 2, file) |
| print("""}""", file=file) |
| print("""complete -F _ffx_completions ffx""", file=file) |
| |
| |
| def find_sub_command_list( |
| sub_commands_root: list[JSONObject], path_elements: Iterable[str] |
| ) -> list[JSONObject]: |
| current_sub_commands = sub_commands_root |
| for element in path_elements: |
| for command in current_sub_commands: |
| if command["name"] == element: |
| assert isinstance(command["command"], dict) |
| assert isinstance(command["command"]["commands"], list) |
| current_sub_commands = command["command"]["commands"] # type: ignore |
| break |
| else: |
| raise ValueError( |
| f"No command {element} found as part of {path_elements} integration." |
| ) |
| return current_sub_commands |
| |
| |
| def load_help_json_from_golden(golden_dir_root: pathlib.Path) -> JSONObject: |
| """Build the command hierarchy from the .golden JSON files. |
| |
| Golden files contains a single command, and are laid out in |
| a directory structure matching the command hierarchy.""" |
| command_jsons: list[JSONObject] = [] |
| for golden_file in golden_dir_root.rglob("*.golden"): |
| sub_command_list = find_sub_command_list( |
| command_jsons, golden_file.parent.relative_to(golden_dir_root).parts |
| ) |
| json_data = json.load(open(golden_file, "r")) |
| if golden_file.stem != json_data["name"].lower(): |
| raise ValueError( |
| f"file stem is {golden_file.stem} and command name is {json_data['name'].lower()}" |
| ) |
| sub_command_list.append( |
| {"name": golden_file.stem, "command": json_data} |
| ) |
| |
| if len(command_jsons) != 1: |
| raise ValueError( |
| f"more than one root command found which is unexpected." |
| ) |
| result = command_jsons[0]["command"] |
| assert isinstance(result, dict) |
| return result |
| |
| |
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| prog="ffx_gen_complete", |
| description="Generate a bash complete script for ffx tool", |
| epilog="Try: source <(python3 scripts/ffx_complete/ffx_gen_complete.py --ffx_help_json_file_path=<(ffx --machine json --help))", |
| ) |
| parser.add_argument( |
| "--ffx_golden_dir_path", |
| default=os.environ.get("FUCHSIA_DIR", ".") |
| + "/src/developer/ffx/tests/cli-goldens/goldens", |
| type=pathlib.Path, |
| help="path to ffx/tests/cli-golden files", |
| metavar="DIR", |
| ) |
| parser.add_argument( |
| "--ffx_help_json_file_path", |
| type=argparse.FileType("r", encoding="UTF-8"), |
| help="path to JSON machine output of ffx help", |
| metavar="FILE", |
| ) |
| args = parser.parse_args() |
| |
| if args.ffx_help_json_file_path: |
| command_json = json.load(args.ffx_help_json_file_path) |
| else: |
| command_json = load_help_json_from_golden(args.ffx_golden_dir_path) |
| |
| root_command = Command.from_dict(command_json) # type: ignore[attr-defined] |
| print_script(root_command, file=sys.stdout) |
| |
| |
| if __name__ == "__main__": |
| main() |