|
| 1 | +"""Structured metadata for Planemo's Click command tree.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from collections.abc import Mapping, Sequence |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +import click |
| 9 | + |
| 10 | +from planemo import __version__ |
| 11 | +from planemo.cli import ( |
| 12 | + COMMAND_ALIASES, |
| 13 | + INTERNAL_COMMANDS, |
| 14 | + list_cmds, |
| 15 | + name_to_command, |
| 16 | +) |
| 17 | + |
| 18 | +SCHEMA_VERSION = "0.1" |
| 19 | + |
| 20 | + |
| 21 | +def iter_command_names(include_internal: bool = False) -> list[str]: |
| 22 | + """Return command names included in public CLI metadata.""" |
| 23 | + commands = list_cmds() |
| 24 | + if not include_internal: |
| 25 | + commands = [command for command in commands if command not in INTERNAL_COMMANDS] |
| 26 | + return commands |
| 27 | + |
| 28 | + |
| 29 | +def load_command_metadata(command_name: str) -> dict[str, Any]: |
| 30 | + """Load metadata for one Planemo command.""" |
| 31 | + return serialize_click_command(command_name, name_to_command(command_name)) |
| 32 | + |
| 33 | + |
| 34 | +def load_planemo_metadata(include_internal: bool = False) -> dict[str, Any]: |
| 35 | + """Load metadata for the Planemo command line interface.""" |
| 36 | + return { |
| 37 | + "schema_version": SCHEMA_VERSION, |
| 38 | + "program": "planemo", |
| 39 | + "planemo_version": __version__, |
| 40 | + "commands": [load_command_metadata(command_name) for command_name in iter_command_names(include_internal)], |
| 41 | + "aliases": dict(sorted(COMMAND_ALIASES.items())), |
| 42 | + } |
| 43 | + |
| 44 | + |
| 45 | +def serialize_click_command(command_name: str, command: click.Command) -> dict[str, Any]: |
| 46 | + """Serialize one Click command into JSON-compatible metadata.""" |
| 47 | + context = click.Context(command, info_name=command_name) |
| 48 | + return { |
| 49 | + "name": command_name, |
| 50 | + "module": f"planemo.commands.cmd_{command_name}", |
| 51 | + "help": command.help, |
| 52 | + "short_help": command.short_help, |
| 53 | + "usage": command.get_usage(context).removeprefix("Usage: "), |
| 54 | + "internal": command_name in INTERNAL_COMMANDS, |
| 55 | + "hidden": getattr(command, "hidden", False), |
| 56 | + "params": [serialize_click_param(param) for param in command.params], |
| 57 | + } |
| 58 | + |
| 59 | + |
| 60 | +def serialize_click_param(param: click.Parameter) -> dict[str, Any]: |
| 61 | + """Serialize one Click parameter into JSON-compatible metadata.""" |
| 62 | + planemo_config = getattr(param, "planemo_config", {}) |
| 63 | + metadata = { |
| 64 | + "kind": "option" if isinstance(param, click.Option) else "argument", |
| 65 | + "name": param.name, |
| 66 | + "opts": list(getattr(param, "opts", [])), |
| 67 | + "secondary_opts": list(getattr(param, "secondary_opts", [])), |
| 68 | + "help": getattr(param, "help", None), |
| 69 | + "required": param.required, |
| 70 | + "human_readable_name": param.human_readable_name, |
| 71 | + "multiple": param.multiple, |
| 72 | + "nargs": param.nargs, |
| 73 | + "type": serialize_click_type(param.type), |
| 74 | + "default": _json_value(planemo_config.get("declared_default", getattr(param, "default", None))), |
| 75 | + "is_flag": getattr(param, "is_flag", False), |
| 76 | + "flag_value": _json_value(getattr(param, "flag_value", None)), |
| 77 | + "envvar": _json_value(getattr(param, "envvar", None)), |
| 78 | + "hidden": getattr(param, "hidden", False), |
| 79 | + "prompt": getattr(param, "prompt", None), |
| 80 | + "planemo_config": _json_value(planemo_config), |
| 81 | + } |
| 82 | + if isinstance(param, click.Option): |
| 83 | + metadata["is_bool_flag"] = param.is_bool_flag |
| 84 | + return metadata |
| 85 | + |
| 86 | + |
| 87 | +def serialize_click_type(param_type: click.ParamType) -> dict[str, Any]: |
| 88 | + """Serialize Click parameter type details where Click exposes them.""" |
| 89 | + metadata: dict[str, Any] = {"name": param_type.name} |
| 90 | + if isinstance(param_type, click.Choice): |
| 91 | + metadata["choices"] = list(param_type.choices) |
| 92 | + metadata["case_sensitive"] = param_type.case_sensitive |
| 93 | + elif isinstance(param_type, click.IntRange): |
| 94 | + metadata.update( |
| 95 | + { |
| 96 | + "min": param_type.min, |
| 97 | + "max": param_type.max, |
| 98 | + "min_open": param_type.min_open, |
| 99 | + "max_open": param_type.max_open, |
| 100 | + } |
| 101 | + ) |
| 102 | + elif isinstance(param_type, click.FloatRange): |
| 103 | + metadata.update( |
| 104 | + { |
| 105 | + "min": param_type.min, |
| 106 | + "max": param_type.max, |
| 107 | + "min_open": param_type.min_open, |
| 108 | + "max_open": param_type.max_open, |
| 109 | + } |
| 110 | + ) |
| 111 | + elif isinstance(param_type, click.Path): |
| 112 | + for attr in ("exists", "file_okay", "dir_okay", "writable", "readable", "resolve_path", "allow_dash"): |
| 113 | + if hasattr(param_type, attr): |
| 114 | + metadata[attr] = getattr(param_type, attr) |
| 115 | + return _json_value(metadata) |
| 116 | + |
| 117 | + |
| 118 | +def _json_value(value: Any) -> Any: |
| 119 | + if value is None or isinstance(value, (str, int, float, bool)): |
| 120 | + return value |
| 121 | + if isinstance(value, Mapping): |
| 122 | + return {str(key): _json_value(nested_value) for key, nested_value in value.items()} |
| 123 | + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): |
| 124 | + return [_json_value(nested_value) for nested_value in value] |
| 125 | + return repr(value) |
0 commit comments