拆分代码;约束cli命令

This commit is contained in:
2026-06-02 14:54:08 +08:00
parent 9b8a517092
commit 40e699e173
17 changed files with 395 additions and 267 deletions
+27 -25
View File
@@ -2,31 +2,33 @@ from __future__ import annotations
import typer
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True)
project_app = typer.Typer(no_args_is_help=True)
network_app = typer.Typer(no_args_is_help=True)
component_app = typer.Typer(no_args_is_help=True)
component_option_app = typer.Typer(no_args_is_help=True)
simulation_app = typer.Typer(no_args_is_help=True)
analysis_app = typer.Typer(no_args_is_help=True)
analysis_leakage_app = typer.Typer(no_args_is_help=True)
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True)
analysis_burst_detection_app = typer.Typer(no_args_is_help=True)
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True)
analysis_burst_location_app = typer.Typer(no_args_is_help=True)
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True)
analysis_risk_app = typer.Typer(no_args_is_help=True)
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True)
data_app = typer.Typer(no_args_is_help=True)
data_timeseries_app = typer.Typer(no_args_is_help=True)
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True)
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True)
data_timeseries_scada_app = typer.Typer(no_args_is_help=True)
data_timeseries_composite_app = typer.Typer(no_args_is_help=True)
data_scada_app = typer.Typer(no_args_is_help=True)
data_scheme_app = typer.Typer(no_args_is_help=True)
data_extension_app = typer.Typer(no_args_is_help=True)
data_misc_app = typer.Typer(no_args_is_help=True)
from .formatters import TJWaterGroup
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup)
project_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
app.add_typer(project_app, name="project")
app.add_typer(network_app, name="network")
+4 -4
View File
@@ -58,8 +58,8 @@ def simulation_run(
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
],
)
@@ -92,8 +92,8 @@ def analysis_burst(
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater --auth-context auth.json data scheme get --name {scheme_name}",
"tjwater --auth-context auth.json data scheme list",
f"tjwater-cli --auth-context auth.json data scheme get --name {scheme_name}",
"tjwater-cli --auth-context auth.json data scheme list",
],
)
+1 -1
View File
@@ -98,7 +98,7 @@ def project_export_inp(
data={"output": str(output), "bytes": len(content)},
ctx=runtime,
duration_ms=duration_dump + duration_download,
next_commands=["tjwater project info"],
next_commands=["tjwater-cli project info"],
)
+2 -1
View File
@@ -13,6 +13,7 @@ import requests
import typer
SCHEMA_VERSION = "tjwater-cli/v1"
CLI_NAME = "tjwater-cli"
DEFAULT_TIMEOUT = 60
DEFAULT_SERVER = "http://192.168.1.114:8000"
@@ -180,7 +181,7 @@ def require_access_token(ctx: RuntimeContext) -> str:
code="UNAUTHENTICATED",
message="missing access token for agent context",
exit_code=3,
next_commands=["tjwater <command> --auth-context /path/to/auth-context.json"],
next_commands=["tjwater-cli <command> --auth-context /path/to/auth-context.json"],
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
import click
import typer.core
class TJWaterGroup(typer.core.TyperGroup):
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
super().format_help(ctx, formatter)
from .helping import build_group_help_appendix
appendix = build_group_help_appendix(ctx)
if appendix:
formatter.write_paragraph()
formatter.write_text(appendix)
+78 -64
View File
@@ -37,7 +37,7 @@ def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]:
def _build_click_context(path: tuple[str, ...]) -> click.Context | None:
root = _click_root_command()
ctx: click.Context = click.Context(root, info_name="tjwater")
ctx: click.Context = click.Context(root, info_name="tjwater-cli")
command: click.Command = root
for token in path:
if not isinstance(command, click.Group):
@@ -54,7 +54,7 @@ def build_usage(path: tuple[str, ...]) -> str | None:
ctx = _build_click_context(path)
if ctx is None:
return None
parts = ["tjwater", *path]
parts = ["tjwater-cli", *path]
for parameter in ctx.command.params:
if not isinstance(parameter, click.Option):
continue
@@ -165,7 +165,7 @@ def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None
has_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
if has_auth and has_required_options:
return example
parts = ["tjwater", "--auth-context", "auth.json", *path]
parts = ["tjwater-cli", "--auth-context", "auth.json", *path]
if ctx is None:
return " ".join(parts)
for parameter in ctx.command.params:
@@ -198,8 +198,8 @@ def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
path = tuple(command_item["command"].split())
doc = get_command_doc(path)
if doc is None and has_subcommands(path):
command_item["usage"] = f"tjwater {' '.join(path)} help"
command_item["example"] = f"tjwater {' '.join(path)} help"
command_item["usage"] = f"tjwater-cli {' '.join(path)} help"
command_item["example"] = f"tjwater-cli {' '.join(path)} help"
else:
existing_examples = [] if doc is None else list(doc.get("examples", []))
command_item["usage"] = build_usage(path) or command_item.get("usage")
@@ -220,11 +220,8 @@ def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None,
return None, False
def emit_help_payload(payload: dict[str, Any], *, json_output: bool, is_index: bool) -> None:
if json_output:
typer.echo(json.dumps(payload, ensure_ascii=False))
else:
typer.echo(render_help_text(payload, is_index=is_index))
def emit_help_payload(payload: dict[str, Any]) -> None:
typer.echo(json.dumps(payload, ensure_ascii=False))
def merge_next_commands(*groups: list[str] | None) -> list[str]:
@@ -259,12 +256,12 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str
return (
{
"command_group": " ".join(group_path),
"usage": f"tjwater {' '.join(group_path)} help",
"examples": [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"],
"usage": f"tjwater-cli {' '.join(group_path)} help",
"examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
},
merge_next_commands(
[f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"],
["tjwater help"],
[f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
["tjwater-cli help"],
),
)
payload, is_index = resolve_help_payload(command_path)
@@ -275,21 +272,21 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str
"usage": payload.get("usage") or usage,
"examples": payload.get("examples", []),
},
merge_next_commands([f"tjwater help {' '.join(command_path)}"], ["tjwater help"]),
merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]),
)
if payload is not None and is_index:
return (
{
"command_group": " ".join(command_path),
"usage": f"tjwater {' '.join(command_path)} help",
"examples": [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"],
"usage": f"tjwater-cli {' '.join(command_path)} help",
"examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
},
merge_next_commands(
[f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"],
["tjwater help"],
[f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
["tjwater-cli help"],
),
)
return ({"usage": usage} if usage else None, ["tjwater help"])
return ({"usage": usage} if usage else None, ["tjwater-cli help"])
def classify_click_error(exc: click.ClickException) -> tuple[str, str]:
@@ -305,55 +302,61 @@ def classify_click_error(exc: click.ClickException) -> tuple[str, str]:
return "CLI 参数错误", "USAGE_ERROR"
def render_help_text(payload: dict[str, Any], *, is_index: bool) -> str:
lines: list[str] = [str(payload.get("summary", ""))]
if is_index:
is_top_level = payload.get("menu_level") == 1
lines.append("")
lines.append("Commands:")
for command in payload.get("commands", []):
lines.append(f" {command['command']}: {command['summary']}")
if not is_top_level and command.get("usage"):
lines.append(f" usage: {command['usage']}")
if not is_top_level and command.get("example"):
lines.append(f" example: {command['example']}")
lines.append("")
if is_top_level:
lines.append("Use `tjwater <menu> help` to see subcommands.")
else:
lines.append("Use `tjwater help --json` for structured output.")
return "\n".join(lines)
def _build_root_help_epilog() -> str:
return "\n".join(
[
"\b",
"Examples:",
" tjwater-cli help",
" tjwater-cli help simulation run",
" tjwater-cli simulation run --help",
]
)
lines.append("")
lines.append(f"Command: {payload['command']}")
lines.append(f"Description: {payload['description']}")
if payload.get("usage"):
lines.append(f"Usage: {payload['usage']}")
options = payload.get("options", [])
if options:
lines.append("")
lines.append("Options:")
for option in options:
suffix = " (required)" if option.get("required") else ""
lines.append(f" --{option['name']}{suffix}: {option['description']}")
def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
lines = ["\b"]
description = payload.get("description")
usage = payload.get("usage")
examples = payload.get("examples", [])
next_commands = payload.get("next_commands", [])
if description:
lines.extend([f"Description: {description}", ""])
if usage:
lines.extend([f"Usage example: {usage}", ""])
if examples:
lines.append("")
lines.append("Examples:")
for example in examples:
lines.append(f" {example}")
lines.append("")
lines.append("Use `tjwater help --json` for structured output.")
lines.extend(f" {example}" for example in examples)
lines.append("")
if next_commands:
lines.append("Next steps:")
lines.extend(f" {command}" for command in next_commands)
lines.append("")
lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"])
return "\n".join(lines)
def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"]
for command in payload.get("commands", [])[:2]:
example = command.get("example")
if example:
lines.append(f" {example}")
return "\n".join(lines)
def build_group_help_appendix(click_ctx: click.Context | None) -> str | None:
path = context_command_path(click_ctx)
if not path:
return _build_root_help_epilog()
payload, is_index = resolve_help_payload(path)
if payload is None or not is_index:
return None
return _build_group_help_epilog(path, payload)
def make_group_help_handler(path_prefix: tuple[str, ...]):
def group_help(
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
) -> None:
def group_help() -> None:
payload, is_index = resolve_help_payload(path_prefix)
if payload is None:
raise CLIError(
@@ -361,9 +364,9 @@ def make_group_help_handler(path_prefix: tuple[str, ...]):
code="COMMAND_NOT_FOUND",
message=f"unknown command path: {' '.join(path_prefix)}",
exit_code=2,
next_commands=["tjwater help"],
next_commands=["tjwater-cli help"],
)
emit_help_payload(payload, json_output=json_output, is_index=is_index)
emit_help_payload(payload)
group_help.__name__ = f"{'_'.join(path_prefix)}_help"
return group_help
@@ -375,19 +378,30 @@ def register_group_help_commands() -> None:
def apply_typer_help_metadata() -> None:
app.help = "TJWater agent CLI"
app.help = "\n".join(
[
"TJWater agent CLI",
"",
"Examples:",
" tjwater-cli help",
" tjwater-cli help simulation run",
" tjwater-cli simulation run --help",
]
)
app.short_help = "TJWater agent CLI"
for group_app, path_prefix in GROUP_HELP_APPS:
for command_info in group_app.registered_commands:
command_path = (*path_prefix, command_info.name)
if command_info.name == "help":
command_info.help = f"显示 {' '.join(path_prefix)} 的帮助信息。"
command_info.help = f"输出 {' '.join(path_prefix)} JSON 帮助信息。"
command_info.short_help = command_info.help
command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"])
command_info.hidden = False
continue
payload = get_command_doc(command_path)
command_info.help = None if payload is None else str(payload.get("summary", ""))
command_info.short_help = command_info.help
command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload)
command_info.hidden = is_hidden_path(command_path)
for group_info in group_app.registered_groups:
group_path = (*path_prefix, group_info.name)
+7 -10
View File
@@ -44,11 +44,8 @@ def root_callback(
register_group_help_commands()
@app.command("help", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def help_command(
ctx: typer.Context,
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
) -> None:
@app.command("help", context_settings={"allow_extra_args": True})
def help_command(ctx: typer.Context) -> None:
command_path = list(ctx.args)
payload, is_index = resolve_help_payload(tuple(command_path))
if payload is None:
@@ -61,13 +58,13 @@ def help_command(
server=None,
request_id=None,
data={
"usage": "tjwater help <command-path>",
"examples": ["tjwater help simulation run", "tjwater simulation help"],
"usage": "tjwater-cli help <command-path>",
"examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"],
},
next_commands=["tjwater help", "tjwater help simulation"],
next_commands=["tjwater-cli help", "tjwater-cli help simulation"],
)
raise typer.Exit(code=2)
emit_help_payload(payload, json_output=json_output, is_index=is_index)
emit_help_payload(payload)
# Must run at import time because tests call runner.invoke(app, ...) directly.
@@ -76,7 +73,7 @@ apply_typer_help_metadata()
def main(argv: list[str] | None = None) -> int:
try:
app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater", standalone_mode=False)
app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False)
return 0
except CLIError as exc:
click_ctx = click.get_current_context(silent=True)
+12 -12
View File
@@ -39,15 +39,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
path=("project", "list"),
summary="列出当前用户可访问项目",
description="调用 /meta/projects 返回项目列表。",
examples=("tjwater --auth-context auth.json project list",),
next_commands=("tjwater --auth-context auth.json project info",),
examples=("tjwater-cli --auth-context auth.json project list",),
next_commands=("tjwater-cli --auth-context auth.json project info",),
output="项目摘要列表",
),
("project", "info"): CommandDoc(
path=("project", "info"),
summary="读取当前项目元数据",
description="调用 /meta/project 返回当前 project 详情。",
examples=("tjwater --auth-context auth.json project info",),
examples=("tjwater-cli --auth-context auth.json project info",),
output="项目元数据",
),
("project", "db-health"): CommandDoc(
@@ -109,8 +109,8 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
CommandOptionDoc("duration", "持续分钟数", required=True),
),
next_commands=(
"tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
),
output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询",
),
@@ -125,11 +125,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
CommandOptionDoc("scheme", "方案名称"),
),
examples=(
"tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01",
"tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01",
),
next_commands=(
"tjwater --auth-context auth.json data scheme get --name burst_case_01",
"tjwater --auth-context auth.json data scheme list",
"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01",
"tjwater-cli --auth-context auth.json data scheme list",
),
output="分析执行结果;方案详情需通过 data scheme 命令单独查询",
),
@@ -138,7 +138,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
summary="执行阀门关闭或隔离分析",
description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。",
examples=(
"tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900",
"tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900",
),
),
("analysis", "flushing"): CommandDoc(
@@ -422,13 +422,13 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -
seen.add(subcommand)
current_path = (*path_prefix, subcommand)
is_group = has_subcommands(current_path)
usage = f"tjwater {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
commands.append(
{
"command": " ".join(current_path),
"summary": get_group_summary(current_path) if is_group else doc.summary,
"usage": usage,
"example": f"tjwater {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
"example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
}
)
return {
@@ -440,7 +440,7 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -
def _build_usage(doc: CommandDoc) -> str:
parts = ["tjwater", *doc.path]
parts = ["tjwater-cli", *doc.path]
for option in doc.options:
placeholder = option.name.upper().replace("-", "_")
if option.required: