Skip to content

Commit a0d765f

Browse files
committed
Add structured CLI and output schemas
1 parent 7ab441b commit a0d765f

15 files changed

Lines changed: 478 additions & 11 deletions

planemo/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
"t": "test",
3333
"s": "serve",
3434
}
35+
INTERNAL_COMMANDS = [
36+
"create_gist",
37+
"shed_download",
38+
]
3539

3640

3741
class PlanemoCliContext(PlanemoContext):
@@ -186,6 +190,7 @@ def planemo(ctx, config, directory, verbose, configure_logging=True):
186190

187191
__all__ = (
188192
"command_function",
193+
"INTERNAL_COMMANDS",
189194
"list_cmds",
190195
"name_to_command",
191196
"planemo",

planemo/cli_metadata.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Module describing the planemo ``cli_metadata`` command."""
2+
3+
import json
4+
5+
import click
6+
7+
from planemo.cli_metadata import (
8+
load_command_metadata,
9+
load_planemo_metadata,
10+
)
11+
12+
13+
@click.command("cli_metadata")
14+
@click.option("--format", "output_format", default="json", type=click.Choice(["json"]), show_default=True)
15+
@click.option("--command", "command_name", help="Only export metadata for the selected command.")
16+
@click.option("--include-internal", is_flag=True, help="Include internal commands not documented as public API.")
17+
def cli(output_format, command_name, include_internal):
18+
"""Export structured metadata for Planemo CLI commands."""
19+
if command_name:
20+
metadata = load_command_metadata(command_name)
21+
else:
22+
metadata = load_planemo_metadata(include_internal=include_internal)
23+
click.echo(json.dumps(metadata, indent=2, sort_keys=True))

planemo/commands/cmd_database_list.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from planemo.database import create_database_source
88

99

10-
@click.command("database_create")
10+
@click.command("database_list")
1111
@options.profile_database_options()
1212
@options.docker_config_options()
1313
@command_function
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Module describing the planemo ``output_schema`` command."""
2+
3+
import json
4+
5+
import click
6+
7+
from planemo.output_schemas import (
8+
SCHEMA_MODELS,
9+
load_output_schemas,
10+
)
11+
12+
13+
@click.command("output_schema")
14+
@click.option("--format", "output_format", default="json", type=click.Choice(["json"]), show_default=True)
15+
@click.option("--schema", "schema_name", type=click.Choice(sorted(SCHEMA_MODELS)), help="Only export one schema.")
16+
def cli(output_format, schema_name):
17+
"""Export JSON Schemas for Planemo machine-readable outputs."""
18+
click.echo(json.dumps(load_output_schemas(schema_name), indent=2, sort_keys=True))

planemo/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,22 @@ def callback(ctx, param, value):
124124
assert name
125125
kwargs["envvar"] = f"PLANEMO_{name.upper()}"
126126

127+
planemo_config = {
128+
"use_global_config": use_global_config,
129+
"extra_global_config_vars": extra_global_config_vars,
130+
"use_env_var": use_env_var,
131+
}
132+
if default_specified:
133+
planemo_config["declared_default"] = default
134+
127135
option = click.option(*args, **kwargs)
128-
return option
136+
137+
def decorator(f):
138+
f = option(f)
139+
f.__click_params__[-1].planemo_config = planemo_config
140+
return f
141+
142+
return decorator
129143

130144

131145
def global_config_path(config_path: Optional[str] = None) -> str:

planemo/output_models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Pydantic models for Planemo machine-readable outputs."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydantic import RootModel
8+
9+
from planemo.test.models import PlanemoTestReport
10+
11+
12+
class PlanemoRunOutputs(RootModel[dict[str, Any]]):
13+
"""Permissive model for ``planemo run --output_json`` outputs."""
14+
15+
16+
__all__ = (
17+
"PlanemoRunOutputs",
18+
"PlanemoTestReport",
19+
)

planemo/output_schemas.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""JSON Schema exports for Planemo machine-readable outputs."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from planemo import __version__
8+
from planemo.output_models import (
9+
PlanemoRunOutputs,
10+
PlanemoTestReport,
11+
)
12+
13+
SCHEMA_VERSION = "0.1"
14+
15+
SCHEMA_MODELS = {
16+
"run-outputs": PlanemoRunOutputs,
17+
"test-report": PlanemoTestReport,
18+
}
19+
20+
21+
def load_output_schemas(schema_name: str | None = None) -> dict[str, Any]:
22+
"""Return Planemo output JSON Schemas."""
23+
schemas = {}
24+
for name, model in SCHEMA_MODELS.items():
25+
if schema_name is None or name == schema_name:
26+
schema = model.model_json_schema()
27+
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
28+
schemas[name] = schema
29+
if schema_name is not None and schema_name not in schemas:
30+
raise KeyError(schema_name)
31+
return {
32+
"schema_version": SCHEMA_VERSION,
33+
"planemo_version": __version__,
34+
"schemas": schemas,
35+
}
36+
37+
38+
__all__ = (
39+
"SCHEMA_MODELS",
40+
"SCHEMA_VERSION",
41+
"load_output_schemas",
42+
)

planemo/test/models.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Pydantic models for Planemo machine-readable test outputs."""
2+
3+
from __future__ import annotations
4+
5+
from typing import (
6+
Any,
7+
Literal,
8+
)
9+
10+
from pydantic import (
11+
BaseModel,
12+
ConfigDict,
13+
field_validator,
14+
model_validator,
15+
)
16+
17+
PlanemoTestStatus = Literal["success", "failure", "error", "skip"]
18+
19+
20+
class PlanemoTestSummary(BaseModel):
21+
model_config = ConfigDict(extra="allow")
22+
23+
num_tests: int
24+
num_failures: int
25+
num_skips: int
26+
num_errors: int
27+
28+
29+
class PlanemoTestCaseData(BaseModel):
30+
model_config = ConfigDict(extra="allow")
31+
32+
status: PlanemoTestStatus | None = None
33+
inputs: dict[str, Any] | None = None
34+
job: dict[str, Any] | None = None
35+
invocation_details: dict[str, Any] | None = None
36+
problem_log: str | None = None
37+
output_problems: list[str] | None = None
38+
execution_problem: str | None = None
39+
start_datetime: str | None = None
40+
end_datetime: str | None = None
41+
42+
@field_validator("status", mode="before")
43+
@classmethod
44+
def normalize_legacy_status(cls, value):
45+
if value == "skipped":
46+
return "skip"
47+
return value
48+
49+
50+
class PlanemoTestCase(BaseModel):
51+
model_config = ConfigDict(extra="allow")
52+
53+
id: str
54+
has_data: bool
55+
data: PlanemoTestCaseData | None = None
56+
doc: str | None = None
57+
test_type: str | None = None
58+
59+
@model_validator(mode="after")
60+
def require_data_when_present(self):
61+
if self.has_data and self.data is None:
62+
raise ValueError("data is required when has_data is true")
63+
return self
64+
65+
66+
class PlanemoTestReport(BaseModel):
67+
model_config = ConfigDict(extra="allow")
68+
69+
version: str = "0.1"
70+
tests: list[PlanemoTestCase]
71+
summary: PlanemoTestSummary | None = None
72+
exit_code: int | None = None
73+
74+
75+
__all__ = (
76+
"PlanemoTestCase",
77+
"PlanemoTestCaseData",
78+
"PlanemoTestReport",
79+
"PlanemoTestStatus",
80+
"PlanemoTestSummary",
81+
)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ lxml
1515
oyaml
1616
packaging
1717
pathvalidate
18+
pydantic>=2
1819
pyyaml
1920
ruamel.yaml
2021
virtualenv

0 commit comments

Comments
 (0)