From f274cf5122f5149f4e093e25dde187c071a1f17c Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 11:11:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86=20tjwater-cli=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/README.md | 68 ++ cli/pyrightconfig.json | 14 + cli/requirements.txt | 3 + cli/tests/conftest.py | 6 + cli/tests/unit/test_tjwater_cli.py | 306 +++++++++ cli/tjwater | 17 + cli/tjwater_agent_cli/__init__.py | 3 + cli/tjwater_agent_cli/__main__.py | 5 + cli/tjwater_agent_cli/apps.py | 83 +++ cli/tjwater_agent_cli/commands_analysis.py | 531 ++++++++++++++ cli/tjwater_agent_cli/commands_data.py | 573 ++++++++++++++++ cli/tjwater_agent_cli/commands_project.py | 224 ++++++ cli/tjwater_agent_cli/common.py | 54 ++ cli/tjwater_agent_cli/core.py | 647 ++++++++++++++++++ cli/tjwater_agent_cli/helping.py | 403 +++++++++++ cli/tjwater_agent_cli/main.py | 115 ++++ cli/tjwater_agent_cli/registry.py | 450 ++++++++++++ .../tjwater_cli_endpoint_scope.md | 0 18 files changed, 3502 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/pyrightconfig.json create mode 100644 cli/requirements.txt create mode 100644 cli/tests/conftest.py create mode 100644 cli/tests/unit/test_tjwater_cli.py create mode 100755 cli/tjwater create mode 100644 cli/tjwater_agent_cli/__init__.py create mode 100644 cli/tjwater_agent_cli/__main__.py create mode 100644 cli/tjwater_agent_cli/apps.py create mode 100644 cli/tjwater_agent_cli/commands_analysis.py create mode 100644 cli/tjwater_agent_cli/commands_data.py create mode 100644 cli/tjwater_agent_cli/commands_project.py create mode 100644 cli/tjwater_agent_cli/common.py create mode 100644 cli/tjwater_agent_cli/core.py create mode 100644 cli/tjwater_agent_cli/helping.py create mode 100644 cli/tjwater_agent_cli/main.py create mode 100644 cli/tjwater_agent_cli/registry.py rename agent_cli_endpoint_scope.md => cli/tjwater_cli_endpoint_scope.md (100%) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..c31819e --- /dev/null +++ b/cli/README.md @@ -0,0 +1,68 @@ +# TJWater CLI + +独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器**直接调用并通过 stdout/stderr 参与管道**。 + +## 直接使用 + +```bash +cd TJWaterServerBinary/cli +./tjwater help --json +``` + +这个入口文件可以直接参与管道: + +```bash +./tjwater help --json | jq +``` + +它会优先使用: +1. `cli/.venv/bin/python` +2. 环境变量 `PYTHON` +3. 当前环境里的 `python` +4. 最后回退到 `python3` + +如果需要,也可以显式走 Python: + +```bash +python -m tjwater_agent_cli help --json +``` + +## 部署到 agent 服务器 + +最简单的方式是把整个 `TJWaterServerBinary/cli/` 文件夹同步到 agent 服务器,然后直接执行: + +```bash +cd TJWaterServerBinary/cli +./tjwater help --json +``` + +如果希望放到 PATH 中: + +```bash +chmod +x tjwater +ln -s /path/to/TJWaterServerBinary/cli/tjwater /usr/local/bin/tjwater +tjwater help --json +``` + +## Python 依赖 + +```bash +cd TJWaterServerBinary/cli +python -m pip install -r requirements.txt +``` + +只保留运行 CLI 必需依赖,不再包含安装包构建相关内容。 + +## 认证上下文 + +CLI 通过 `--auth-context` 读取 JSON 文件。常用字段: + +```json +{ + "server": "http://backend-host:8000", + "access_token": "...", + "project_id": "...", + "network": "...", + "username": "..." +} +``` diff --git a/cli/pyrightconfig.json b/cli/pyrightconfig.json new file mode 100644 index 0000000..39b6dd1 --- /dev/null +++ b/cli/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "include": [ + "tjwater_agent_cli", + "tests" + ], + "executionEnvironments": [ + { + "root": ".", + "extraPaths": [ + "." + ] + } + ] +} diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..8eb3f9b --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,3 @@ +click>=8.1,<9 +requests>=2.31,<3 +typer>=0.12,<1 diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..17cdbe1 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,6 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py new file mode 100644 index 0000000..c546396 --- /dev/null +++ b/cli/tests/unit/test_tjwater_cli.py @@ -0,0 +1,306 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from tjwater_agent_cli import core +from tjwater_agent_cli.main import app, main + + +runner = CliRunner() + + +class DummyResponse: + def __init__(self, *, status_code=200, json_data=None, text="", headers=None, content=None): + self.status_code = status_code + self._json_data = json_data + self.text = text + self.headers = headers or {"content-type": "application/json"} + self.content = content if content is not None else text.encode("utf-8") + + @property + def ok(self): + return 200 <= self.status_code < 300 + + def json(self): + if self._json_data is None: + raise ValueError("no json") + return self._json_data + + +def test_load_auth_context_supports_aliases(tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"base_url":"http://server","token":"abc","projectId":"p1","userId":"u1","username":"tester","projectCode":"net1"}', + encoding="utf-8", + ) + + auth = core.load_auth_context(auth_path) + + assert auth.server == "http://server" + assert auth.access_token == "abc" + assert auth.project_id == "p1" + assert auth.user_id == "u1" + assert auth.username == "tester" + assert auth.network == "net1" + + +def test_build_runtime_context_uses_default_server(monkeypatch): + monkeypatch.delenv("TJWATER_SERVER", raising=False) + monkeypatch.delenv("TJWATER_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("TJWATER_PROJECT_ID", raising=False) + monkeypatch.delenv("TJWATER_USER_ID", raising=False) + monkeypatch.delenv("TJWATER_USERNAME", raising=False) + monkeypatch.delenv("TJWATER_NETWORK", raising=False) + monkeypatch.delenv("TJWATER_EXTRA_HEADERS", raising=False) + + runtime = core.build_runtime_context( + server=None, + auth_context_path=None, + scheme=None, + timeout=core.DEFAULT_TIMEOUT, + request_id="req-1", + ) + + assert runtime.server == core.DEFAULT_SERVER + + +def test_help_json_lists_commands(): + result = runner.invoke(app, ["help", "--json"]) + + assert result.exit_code == 0 + assert '"schema_version": "tjwater-cli/v1"' in result.stdout + assert '"command": "project"' in result.stdout + assert '"command": "analysis"' in result.stdout + assert '"menu_level": 1' in result.stdout + assert '"command": "project list"' not in result.stdout + + +def test_help_defaults_to_text(): + result = runner.invoke(app, ["help"]) + + assert result.exit_code == 0 + assert "Commands:" in result.stdout + assert "project: 项目与项目级元数据相关命令。" in result.stdout + assert "analysis: 分析计算与诊断相关命令。" in result.stdout + assert "Use `tjwater help` to see subcommands." in result.stdout + assert "usage: tjwater project help" not in result.stdout + assert "example: tjwater project help" not in result.stdout + assert "project list: 列出当前用户可访问项目" not in result.stdout + assert '"schema_version": "tjwater-cli/v1"' not in result.stdout + + +def test_simulation_help_lists_subcommands(): + result = runner.invoke(app, ["simulation", "help"]) + + assert result.exit_code == 0 + assert "模拟运行与调度相关命令。" in result.stdout + assert "simulation run: 触发指定绝对时间的模拟运行" in result.stdout + assert "usage: tjwater simulation run --start-time --duration " in result.stdout + assert "example: tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + + +def test_nested_group_help_lists_examples(): + result = runner.invoke(app, ["analysis", "leakage", "help"]) + + assert result.exit_code == 0 + assert "漏损分析相关命令。" in result.stdout + assert "analysis leakage identify: 执行漏损识别" in result.stdout + assert "example: tjwater --auth-context auth.json analysis leakage identify" in result.stdout + + +def test_analysis_help_uses_group_summaries_for_nested_groups(): + result = runner.invoke(app, ["analysis", "help"]) + + assert result.exit_code == 0 + assert "analysis leakage: 漏损分析相关命令。" in result.stdout + assert "analysis burst-detection: 爆管检测相关命令。" in result.stdout + assert "analysis burst-location" not in result.stdout + assert "analysis risk" not in result.stdout + assert "analysis leakage: 执行漏损识别" not in result.stdout + assert "example: 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" in result.stdout + assert "example: tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" in result.stdout + + +def test_bare_analysis_uses_typer_help_with_descriptions(): + result = runner.invoke(app, ["analysis"]) + + assert result.exit_code == 2 + assert "分析计算与诊断相关命令。" in result.stdout + assert "burst 执行爆管分析" in result.stdout + assert "valve 执行阀门关闭或隔离分析" in result.stdout + assert "leakage 漏损分析相关命令。" in result.stdout + assert "burst-location" not in result.stdout + assert "risk" not in result.stdout + + +def test_leaf_help_shows_usage_and_example(): + result = runner.invoke(app, ["help", "simulation", "run"]) + + assert result.exit_code == 0 + assert "Command: simulation run" in result.stdout + assert "结果需后续通过 data timeseries 在对应时间段查询" in result.stdout + assert "Usage: tjwater simulation run --start-time --duration " in result.stdout + assert "Examples:" in result.stdout + assert "tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + + +def test_project_help_uses_legal_kind_example(): + result = runner.invoke(app, ["project", "help"]) + + assert result.exit_code == 0 + assert "example: tjwater --auth-context auth.json project data --kind scada-info" in result.stdout + assert "--kind time" not in result.stdout + + +def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + burst_path = tmp_path / "burst.json" + burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8") + + def fake_request(**kwargs): + return DummyResponse(text="success", headers={"content-type": "text/plain"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "--auth-context", + str(auth_path), + "analysis", + "burst", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "30", + "--burst-file", + str(burst_path), + "--scheme", + "burst_case_01", + ], + ) + + assert result.exit_code == 0 + assert '"summary": "爆管分析执行成功"' in result.stdout + assert '"tjwater --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout + assert '"tjwater --auth-context auth.json data scheme list"' in result.stdout + + +def test_main_missing_option_error_includes_usage_and_next_step(capsys): + exit_code = main(["simulation", "run"]) + stdout = capsys.readouterr().out + + assert exit_code == 2 + assert '"summary": "缺少参数"' in stdout + assert '"code": "MISSING_PARAMETER"' in stdout + assert '"usage": "tjwater simulation run --start-time --duration "' in stdout + assert '"tjwater help simulation run"' in stdout + + +def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): + exit_code = main(["analysis"]) + stdout = capsys.readouterr().out + + assert exit_code == 0 + assert "Usage: tjwater analysis" in stdout + assert "分析计算与诊断相关命令。" in stdout + assert '"ok": false' not in stdout + + +def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","project_id":"pid","network":"demo"}', + encoding="utf-8", + ) + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(json_data=[{"project_id": "pid", "name": "Demo"}]) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke(app, ["--auth-context", str(auth_path), "project", "list"]) + + assert result.exit_code == 0 + assert '"ok": true' in result.stdout + assert captured["headers"]["Authorization"] == "Bearer abc" + assert captured["url"] == "http://server/api/v1/meta/projects" + + +def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + captured = {} + + def fake_request(**kwargs): + captured.update(kwargs) + return DummyResponse(json_data={"status": "success", "message": "Simulation started"}) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + [ + "--auth-context", + str(auth_path), + "simulation", + "run", + "--start-time", + "2025-01-02T03:04:05+08:00", + "--duration", + "30", + ], + ) + + assert result.exit_code == 0 + assert captured["json"] == { + "name": "demo", + "simulation_date": "2025-01-02", + "start_time": "03:04:05+08:00", + "duration": 30, + } + assert '"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"' in result.stdout + assert '"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"' in result.stdout + + +def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): + auth_path = tmp_path / "auth.json" + auth_path.write_text( + '{"server":"http://server","access_token":"abc","network":"demo"}', + encoding="utf-8", + ) + output = tmp_path / "demo.inp" + calls = [] + + def fake_request(**kwargs): + calls.append(kwargs["url"]) + if kwargs["url"].endswith("/dumpinp/"): + return DummyResponse(json_data=True) + return DummyResponse( + headers={"content-type": "application/octet-stream"}, + content=b"inp-content", + text="inp-content", + ) + + monkeypatch.setattr(core.requests, "request", fake_request) + + result = runner.invoke( + app, + ["--auth-context", str(auth_path), "project", "export-inp", "--output", str(output)], + ) + + assert result.exit_code == 0 + assert output.read_bytes() == b"inp-content" + assert calls == [ + "http://server/api/v1/dumpinp/", + "http://server/api/v1/downloadinp/", + ] diff --git a/cli/tjwater b/cli/tjwater new file mode 100755 index 0000000..e2428c8 --- /dev/null +++ b/cli/tjwater @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -x "$ROOT/.venv/bin/python" ]; then + PYTHON_BIN="$ROOT/.venv/bin/python" +elif [ -n "${PYTHON:-}" ]; then + PYTHON_BIN="$PYTHON" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + PYTHON_BIN="python3" +fi + +export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" +exec "$PYTHON_BIN" -m tjwater_agent_cli "$@" diff --git a/cli/tjwater_agent_cli/__init__.py b/cli/tjwater_agent_cli/__init__.py new file mode 100644 index 0000000..d1b1862 --- /dev/null +++ b/cli/tjwater_agent_cli/__init__.py @@ -0,0 +1,3 @@ +from .main import app, main + +__all__ = ["app", "main"] diff --git a/cli/tjwater_agent_cli/__main__.py b/cli/tjwater_agent_cli/__main__.py new file mode 100644 index 0000000..8462220 --- /dev/null +++ b/cli/tjwater_agent_cli/__main__.py @@ -0,0 +1,5 @@ +from .main import console_entry + + +if __name__ == "__main__": + console_entry() diff --git a/cli/tjwater_agent_cli/apps.py b/cli/tjwater_agent_cli/apps.py new file mode 100644 index 0000000..13de6b5 --- /dev/null +++ b/cli/tjwater_agent_cli/apps.py @@ -0,0 +1,83 @@ +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) + +app.add_typer(project_app, name="project") +app.add_typer(network_app, name="network") +app.add_typer(component_app, name="component") +component_app.add_typer(component_option_app, name="option") +app.add_typer(simulation_app, name="simulation") +app.add_typer(analysis_app, name="analysis") +analysis_app.add_typer(analysis_sensor_placement_app, name="sensor-placement") +analysis_app.add_typer(analysis_leakage_app, name="leakage") +analysis_leakage_app.add_typer(analysis_leakage_schemes_app, name="schemes") +analysis_app.add_typer(analysis_burst_detection_app, name="burst-detection") +analysis_burst_detection_app.add_typer(analysis_burst_detection_schemes_app, name="schemes") +analysis_app.add_typer(analysis_burst_location_app, name="burst-location") +analysis_burst_location_app.add_typer(analysis_burst_location_schemes_app, name="schemes") +analysis_app.add_typer(analysis_risk_app, name="risk") +app.add_typer(data_app, name="data") +data_app.add_typer(data_timeseries_app, name="timeseries") +data_timeseries_app.add_typer(data_timeseries_realtime_app, name="realtime") +data_timeseries_app.add_typer(data_timeseries_scheme_app, name="scheme") +data_timeseries_app.add_typer(data_timeseries_scada_app, name="scada") +data_timeseries_app.add_typer(data_timeseries_composite_app, name="composite") +data_app.add_typer(data_scada_app, name="scada") +data_app.add_typer(data_scheme_app, name="scheme") +data_app.add_typer(data_extension_app, name="extension") +data_app.add_typer(data_misc_app, name="misc") + +GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [ + (project_app, ("project",)), + (network_app, ("network",)), + (component_app, ("component",)), + (component_option_app, ("component", "option")), + (simulation_app, ("simulation",)), + (analysis_app, ("analysis",)), + (analysis_sensor_placement_app, ("analysis", "sensor-placement")), + (analysis_leakage_app, ("analysis", "leakage")), + (analysis_leakage_schemes_app, ("analysis", "leakage", "schemes")), + (analysis_burst_detection_app, ("analysis", "burst-detection")), + (analysis_burst_detection_schemes_app, ("analysis", "burst-detection", "schemes")), + (analysis_burst_location_app, ("analysis", "burst-location")), + (analysis_burst_location_schemes_app, ("analysis", "burst-location", "schemes")), + (analysis_risk_app, ("analysis", "risk")), + (data_app, ("data",)), + (data_timeseries_app, ("data", "timeseries")), + (data_timeseries_realtime_app, ("data", "timeseries", "realtime")), + (data_timeseries_scheme_app, ("data", "timeseries", "scheme")), + (data_timeseries_scada_app, ("data", "timeseries", "scada")), + (data_timeseries_composite_app, ("data", "timeseries", "composite")), + (data_scada_app, ("data", "scada")), + (data_scheme_app, ("data", "scheme")), + (data_extension_app, ("data", "extension")), + (data_misc_app, ("data", "misc")), +] + +TOP_LEVEL_COMMANDS = {"help", "project", "network", "component", "simulation", "analysis", "data"} diff --git a/cli/tjwater_agent_cli/commands_analysis.py b/cli/tjwater_agent_cli/commands_analysis.py new file mode 100644 index 0000000..4a3121a --- /dev/null +++ b/cli/tjwater_agent_cli/commands_analysis.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path +from typing import Annotated + +import typer + +from .apps import ( + analysis_app, + analysis_burst_detection_app, + analysis_burst_detection_schemes_app, + analysis_burst_location_app, + analysis_burst_location_schemes_app, + analysis_leakage_app, + analysis_leakage_schemes_app, + analysis_risk_app, + analysis_sensor_placement_app, + simulation_app, +) +from .common import emit_api, runtime_context +from .core import ( + CLIError, + emit_success, + parse_burst_file, + parse_optional_dataset_file, + parse_time_with_timezone, + parse_valve_setting_file, + request_json, + require_network, + require_username, + resolve_scheme, +) + + +@simulation_app.command("run") +def simulation_run( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续分钟数")], +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + parsed = parse_time_with_timezone(start_time, option_name="--start-time") + end_time = (parsed + timedelta(minutes=duration)).isoformat() + body = { + "name": network, + "simulation_date": parsed.date().isoformat(), + "start_time": parsed.timetz().replace(microsecond=0).isoformat(), + "duration": duration, + } + emit_api( + ctx, + summary="触发模拟成功", + method="POST", + path="/runsimulationmanuallybydate/", + json_body=body, + 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}", + ], + ) + + +@analysis_app.command("burst") +def analysis_burst( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], + burst_file: Annotated[Path, typer.Option("--burst-file", help="爆管输入 JSON 文件")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + ids, sizes = parse_burst_file(burst_file) + scheme_name = resolve_scheme(runtime, scheme, required=True) + params = { + "network": require_network(runtime), + "modify_pattern_start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "burst_ID": ids, + "burst_size": sizes, + "modify_total_duration": duration, + "scheme_name": scheme_name, + } + emit_api( + ctx, + summary="爆管分析执行成功", + method="GET", + path="/burst_analysis/", + params=params, + 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", + ], + ) + + +@analysis_app.command("valve") +def analysis_valve( + ctx: typer.Context, + mode: Annotated[str, typer.Option("--mode", help="close|isolation")], + start_time: Annotated[str | None, typer.Option("--start-time", help="close 模式需要")] = None, + valve: Annotated[list[str] | None, typer.Option("--valve", help="阀门 ID,可重复")] = None, + element: Annotated[list[str] | None, typer.Option("--element", help="isolation 模式的事故元素,可重复")] = None, + disabled_valve: Annotated[list[str] | None, typer.Option("--disabled-valve", help="故障阀门,可重复")] = None, + duration: Annotated[int | None, typer.Option("--duration", help="close 模式持续秒数")] = None, +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + if mode == "close": + if not start_time or not valve: + raise CLIError( + "CLI 参数错误", + code="INVALID_VALVE_CLOSE_ARGS", + message="close mode requires --start-time and at least one --valve", + exit_code=2, + ) + params = { + "network": network, + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "valves": valve, + "duration": duration or 900, + } + emit_api( + ctx, + summary="阀门关闭分析执行成功", + method="GET", + path="/valve_close_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + return + if mode == "isolation": + if not element: + raise CLIError( + "CLI 参数错误", + code="INVALID_VALVE_ISOLATION_ARGS", + message="isolation mode requires at least one --element", + exit_code=2, + ) + params = {"network": network, "accident_element": element} + if disabled_valve: + params["disabled_valves"] = disabled_valve + emit_api( + ctx, + summary="阀门隔离分析执行成功", + method="GET", + path="/valve_isolation_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_MODE", + message="--mode must be close or isolation", + exit_code=2, + ) + + +@analysis_app.command("flushing") +def analysis_flushing( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + valve_setting_file: Annotated[Path, typer.Option("--valve-setting-file", help="阀门开度 JSON 文件")], + drainage_node: Annotated[str, typer.Option("--drainage-node", help="排污节点")], + flow: Annotated[float, typer.Option("--flow", help="冲洗流量")], + duration: Annotated[int | None, typer.Option("--duration", help="持续秒数")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + valves, openings = parse_valve_setting_file(valve_setting_file) + params = { + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "valves": valves, + "valves_k": openings, + "drainage_node_ID": drainage_node, + "flush_flow": flow, + "duration": duration or 900, + } + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + emit_api( + ctx, + summary="冲洗分析执行成功", + method="GET", + path="/flushing_analysis/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_app.command("age") +def analysis_age( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="水龄分析执行成功", + method="GET", + path="/age_analysis/", + params={ + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "duration": duration, + }, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_app.command("contaminant") +def analysis_contaminant( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + duration: Annotated[int, typer.Option("--duration", help="持续秒数")], + source_node: Annotated[str, typer.Option("--source-node", help="污染源节点")], + concentration: Annotated[float, typer.Option("--concentration", help="浓度")], + pattern: Annotated[str | None, typer.Option("--pattern", help="模式 ID")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + params = { + "network": require_network(runtime), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "source": source_node, + "concentration": concentration, + "duration": duration, + } + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + if pattern: + params["pattern"] = pattern + emit_api( + ctx, + summary="污染物模拟执行成功", + method="GET", + path="/contaminant_simulation/", + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_sensor_placement_app.command("kmeans") +def analysis_sensor_placement_kmeans( + ctx: typer.Context, + count: Annotated[int, typer.Option("--count", help="传感器数量")], + min_diameter: Annotated[int, typer.Option("--min-diameter", help="最小管径")] = 0, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "name": require_network(runtime), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "sensor_number": count, + "min_diameter": min_diameter, + "username": require_username(runtime), + } + emit_api( + ctx, + summary="传感器选址执行成功", + method="POST", + path="/pressure_sensor_placement_kmeans/", + json_body=body, + require_auth=True, + require_network_ctx=True, + require_username_ctx=True, + ) + + +@analysis_leakage_app.command("identify") +def analysis_leakage_identify( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "network": require_network(runtime), + "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + } + emit_api( + ctx, + summary="漏损识别执行成功", + method="POST", + path="/leakage/identify/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_leakage_schemes_app.command("list") +def analysis_leakage_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取漏损方案列表成功", + method="GET", + path="/leakage/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_leakage_schemes_app.command("get") +def analysis_leakage_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取漏损方案详情成功", + method="GET", + path=f"/leakage/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_app.command("detect") +def analysis_burst_detection_detect( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, +) -> None: + runtime = runtime_context(ctx) + body = { + "network": require_network(runtime), + "scada_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + } + emit_api( + ctx, + summary="爆管检测执行成功", + method="POST", + path="/burst-detection/detect/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_schemes_app.command("list") +def analysis_burst_detection_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管检测方案列表成功", + method="GET", + path="/burst-detection/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_detection_schemes_app.command("get") +def analysis_burst_detection_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管检测方案详情成功", + method="GET", + path=f"/burst-detection/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_app.command("locate") +def analysis_burst_location_locate( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="RFC3339 开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="RFC3339 结束时间")], + burst_leakage: Annotated[float, typer.Option("--burst-leakage", help="爆管漏水量")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + data_source: Annotated[str, typer.Option("--data-source", help="monitoring|simulation")] = "monitoring", + pressure_scada_id: Annotated[list[str] | None, typer.Option("--pressure-scada-id", help="压力 SCADA ID,可重复")] = None, + flow_scada_id: Annotated[list[str] | None, typer.Option("--flow-scada-id", help="流量 SCADA ID,可重复")] = None, + pressure_file: Annotated[Path | None, typer.Option("--pressure-file", help="包含 burst_pressure/normal_pressure 的 JSON 文件")] = None, + flow_file: Annotated[Path | None, typer.Option("--flow-file", help="包含 burst_flow/normal_flow 的 JSON 文件")] = None, + use_scada_flow: Annotated[bool, typer.Option("--use-scada-flow", help="启用 SCADA 流量")] = False, +) -> None: + runtime = runtime_context(ctx) + pressure_payload = parse_optional_dataset_file(pressure_file, label="pressure") or {} + flow_payload = parse_optional_dataset_file(flow_file, label="flow") or {} + body = { + "network": require_network(runtime), + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "data_source": data_source, + "scada_burst_start": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "scada_burst_end": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + "burst_leakage": burst_leakage, + "use_scada_flow": use_scada_flow, + } + if pressure_scada_id: + body["pressure_scada_ids"] = pressure_scada_id + if flow_scada_id: + body["flow_scada_ids"] = flow_scada_id + if isinstance(pressure_payload, dict): + body.update({key: value for key, value in pressure_payload.items() if key in {"burst_pressure", "normal_pressure"}}) + if isinstance(flow_payload, dict): + body.update({key: value for key, value in flow_payload.items() if key in {"burst_flow", "normal_flow"}}) + emit_api( + ctx, + summary="爆管定位执行成功", + method="POST", + path="/burst-location/locate/", + json_body=body, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_schemes_app.command("list") +def analysis_burst_location_schemes_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位方案列表成功", + method="GET", + path="/burst-location/schemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_burst_location_schemes_app.command("get") +def analysis_burst_location_schemes_get( + ctx: typer.Context, + scheme_name: Annotated[str, typer.Argument(help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位方案详情成功", + method="GET", + path=f"/burst-location/schemes/{scheme_name}", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("pipe-now") +def analysis_risk_pipe_now( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取当前管道风险成功", + method="GET", + path="/getpiperiskprobabilitynow/", + params={"network": require_network(runtime), "pipe_id": pipe}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("pipe-history") +def analysis_risk_pipe_history( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取历史管道风险成功", + method="GET", + path="/getpiperiskprobability/", + params={"network": require_network(runtime), "pipe_id": pipe}, + require_auth=True, + require_network_ctx=True, + ) + + +@analysis_risk_app.command("network") +def analysis_risk_network(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + probabilities, duration_prob = request_json( + runtime, + method="GET", + path="/getnetworkpiperiskprobabilitynow/", + params={"network": network}, + require_auth=True, + require_network_ctx=True, + ) + geometries, duration_geo = request_json( + runtime, + method="GET", + path="/getpiperiskprobabilitygeometries/", + params={"network": network}, + require_auth=True, + require_network_ctx=True, + ) + emit_success( + summary="读取全网风险成功", + data={"probabilities": probabilities, "geometries": geometries}, + ctx=runtime, + duration_ms=duration_prob + duration_geo, + ) diff --git a/cli/tjwater_agent_cli/commands_data.py b/cli/tjwater_agent_cli/commands_data.py new file mode 100644 index 0000000..4491630 --- /dev/null +++ b/cli/tjwater_agent_cli/commands_data.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from .apps import ( + data_extension_app, + data_misc_app, + data_scada_app, + data_scheme_app, + data_timeseries_composite_app, + data_timeseries_realtime_app, + data_timeseries_scada_app, + data_timeseries_scheme_app, +) +from .common import emit_api, runtime_context +from .core import CLIError, parse_time_with_timezone, require_network, resolve_scheme + + +def _scheme_type_option(scheme_type: str | None) -> str: + return scheme_type or "simulation" + + +@data_timeseries_realtime_app.command("links") +def data_realtime_links( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + emit_api( + ctx, + summary="读取实时管道数据成功", + method="GET", + path="/realtime/links", + params={ + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("nodes") +def data_realtime_nodes( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + emit_api( + ctx, + summary="读取实时节点数据成功", + method="GET", + path="/realtime/nodes", + params={ + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("simulation-by-id-time") +def data_realtime_simulation_by_id_time( + ctx: typer.Context, + id: Annotated[str, typer.Option("--id", help="元素 ID")], + type: Annotated[str, typer.Option("--type", help="pipe|junction")], + time: Annotated[str, typer.Option("--time", help="查询时间")], +) -> None: + emit_api( + ctx, + summary="读取实时模拟数据成功", + method="GET", + path="/realtime/query/by-id-time", + params={ + "id": id, + "type": type, + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_realtime_app.command("simulation-by-time-property") +def data_realtime_simulation_by_time_property( + ctx: typer.Context, + type: Annotated[str, typer.Option("--type", help="pipe|junction")], + time: Annotated[str, typer.Option("--time", help="查询时间")], + property: Annotated[str, typer.Option("--property", help="属性名")], +) -> None: + emit_api( + ctx, + summary="读取实时属性聚合数据成功", + method="GET", + path="/realtime/query/by-time-property", + params={ + "type": type, + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + "property": property, + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("links") +def data_scheme_links( + ctx: typer.Context, + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案管道数据成功", + method="GET", + path="/scheme/links", + params={ + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("node-field") +def data_scheme_node_field( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], + field: Annotated[str, typer.Option("--field", help="字段名")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案节点字段成功", + method="GET", + path=f"/scheme/nodes/{node}/field", + params={ + "field": field, + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_scheme_app.command("simulation") +def data_scheme_simulation( + ctx: typer.Context, + query: Annotated[str, typer.Option("--query", help="by-id-time|by-scheme-time-property")], + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, + id: Annotated[str | None, typer.Option("--id", help="元素 ID")] = None, + time: Annotated[str, typer.Option("--time", help="查询时间")] = "", + type: Annotated[str, typer.Option("--type", help="pipe|junction")] = "pipe", + property: Annotated[str | None, typer.Option("--property", help="属性名")] = None, +) -> None: + runtime = runtime_context(ctx) + params = { + "scheme_name": resolve_scheme(runtime, scheme, required=True), + "scheme_type": _scheme_type_option(scheme_type), + "query_time": parse_time_with_timezone(time, option_name="--time").isoformat(), + "type": type, + } + if query == "by-id-time": + if not id: + raise CLIError( + "CLI 参数错误", + code="ID_REQUIRED", + message="--id is required for --query by-id-time", + exit_code=2, + ) + params["id"] = id + emit_api( + ctx, + summary="读取方案单点模拟数据成功", + method="GET", + path="/scheme/query/by-id-time", + params=params, + require_auth=True, + require_project=True, + ) + return + if query == "by-scheme-time-property": + if not property: + raise CLIError( + "CLI 参数错误", + code="PROPERTY_REQUIRED", + message="--property is required for --query by-scheme-time-property", + exit_code=2, + ) + params["property"] = property + emit_api( + ctx, + summary="读取方案属性聚合数据成功", + method="GET", + path="/scheme/query/by-scheme-time-property", + params=params, + require_auth=True, + require_project=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_QUERY", + message="--query must be by-id-time or by-scheme-time-property", + exit_code=2, + ) + + +@data_timeseries_scada_app.command("query") +def data_scada_query( + ctx: typer.Context, + device_id: Annotated[list[str], typer.Option("--device-id", help="设备 ID,可重复")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], + field: Annotated[str | None, typer.Option("--field", help="字段名")] = None, +) -> None: + path = "/scada/by-ids-field-time-range" if field else "/scada/by-ids-time-range" + params = { + "device_ids": ",".join(device_id), + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + } + if field: + params["field"] = field + emit_api( + ctx, + summary="读取 SCADA 时序成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_project=True, + ) + + +@data_timeseries_composite_app.callback(invoke_without_command=True) +def data_timeseries_composite( + ctx: typer.Context, + kind: Annotated[str | None, typer.Option("--kind", help="scada-simulation|element-simulation|element-scada")] = None, + feature: Annotated[list[str] | None, typer.Option("--feature", help="特征值,可重复")] = None, + start_time: Annotated[str | None, typer.Option("--start-time", help="开始时间")] = None, + end_time: Annotated[str | None, typer.Option("--end-time", help="结束时间")] = None, + pipe: Annotated[str | None, typer.Option("--pipe", help="pipeline-health 用管道 ID")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="方案名称")] = None, + scheme_type: Annotated[str | None, typer.Option("--scheme-type", help="方案类型")] = None, + use_cleaned: Annotated[bool, typer.Option("--use-cleaned", help="element-scada 使用清洗值")] = False, +) -> None: + _ = pipe + if ctx.invoked_subcommand is not None: + return + if not kind or not start_time or not end_time: + raise CLIError( + "CLI 参数错误", + code="INVALID_COMPOSITE_ARGS", + message="composite query requires --kind, --start-time, and --end-time", + exit_code=2, + ) + runtime = runtime_context(ctx) + params = { + "start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(), + "end_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + } + if kind == "scada-simulation": + if not feature: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="--feature is required for scada-simulation", + exit_code=2, + ) + params["device_ids"] = ",".join(feature) + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + params["scheme_type"] = _scheme_type_option(scheme_type) + emit_api( + ctx, + summary="读取复合 SCADA-模拟数据成功", + method="GET", + path="/composite/scada-simulation", + params=params, + require_auth=True, + require_project=True, + ) + return + if kind == "element-simulation": + if not feature: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="--feature is required for element-simulation", + exit_code=2, + ) + params["feature_infos"] = ",".join(feature) + scheme_name = resolve_scheme(runtime, scheme) + if scheme_name: + params["scheme_name"] = scheme_name + params["scheme_type"] = _scheme_type_option(scheme_type) + emit_api( + ctx, + summary="读取复合元素模拟数据成功", + method="GET", + path="/composite/element-simulation", + params=params, + require_auth=True, + require_project=True, + ) + return + if kind == "element-scada": + if not feature or len(feature) != 1: + raise CLIError( + "CLI 参数错误", + code="FEATURE_REQUIRED", + message="element-scada requires exactly one --feature as element_id", + exit_code=2, + ) + params["element_id"] = feature[0] + params["use_cleaned"] = use_cleaned + emit_api( + ctx, + summary="读取元素关联 SCADA 数据成功", + method="GET", + path="/composite/element-scada", + params=params, + require_auth=True, + require_project=True, + ) + return + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="--kind must be scada-simulation, element-simulation, or element-scada", + exit_code=2, + ) + + +@data_timeseries_composite_app.command("pipeline-health") +def data_composite_pipeline_health( + ctx: typer.Context, + pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")], + start_time: Annotated[str, typer.Option("--start-time", help="开始时间")], + end_time: Annotated[str, typer.Option("--end-time", help="结束时间")], +) -> None: + _ = pipe, start_time + emit_api( + ctx, + summary="读取管道健康预测成功", + method="GET", + path="/composite/pipeline-health-prediction", + params={ + "network_name": require_network(runtime_context(ctx)), + "query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(), + }, + require_auth=True, + require_project=True, + require_network_ctx=True, + ) + + +def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]: + mapping = { + ("device", "schema"): ("/getscadadeviceschema/", {}), + ("device", "get"): ("/getscadadevice/", {"id_param": "id"}), + ("device", "list"): ("/getallscadadevices/", {}), + ("device-data", "schema"): ("/getscadadevicedataschema/", {}), + ("device-data", "get"): ("/getscadadevicedata/", {"id_param": "device_id"}), + ("element", "schema"): ("/getscadaelementschema/", {}), + ("element", "get"): ("/getscadaelement/", {"id_param": "id"}), + ("element", "list"): ("/getscadaelements/", {}), + ("info", "schema"): ("/getscadainfoschema/", {}), + ("info", "get"): ("/getscadainfo/", {"id_param": "id"}), + ("info", "list"): ("/getallscadainfo/", {}), + } + result = mapping.get((kind, action)) + if result is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_SCADA_KIND", + message=f"unsupported scada {action} kind: {kind}", + exit_code=2, + ) + return result + + +@data_scada_app.command("schema") +def data_scada_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], +) -> None: + runtime = runtime_context(ctx) + path, _ = _scada_mapping(kind, "schema") + emit_api( + ctx, + summary="读取 SCADA schema 成功", + method="GET", + path=path, + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scada_app.command("get") +def data_scada_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|device-data|element|info")], + id: Annotated[str, typer.Option("--id", help="记录 ID")], +) -> None: + runtime = runtime_context(ctx) + path, meta = _scada_mapping(kind, "get") + params = {"network": require_network(runtime), meta["id_param"]: id} + emit_api( + ctx, + summary="读取 SCADA 数据成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scada_app.command("list") +def data_scada_list( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="device|element|info")], +) -> None: + runtime = runtime_context(ctx) + path, _ = _scada_mapping(kind, "list") + emit_api( + ctx, + summary="读取 SCADA 列表成功", + method="GET", + path=path, + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("schema") +def data_scheme_schema(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案 schema 成功", + method="GET", + path="/getschemeschema/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("get") +def data_scheme_get( + ctx: typer.Context, + name: Annotated[str, typer.Option("--name", help="方案名称")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案成功", + method="GET", + path="/getscheme/", + params={"network": require_network(runtime), "schema_name": name}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_scheme_app.command("list") +def data_scheme_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取方案列表成功", + method="GET", + path="/getallschemes/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("keys") +def data_extension_keys(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据键成功", + method="GET", + path="/getallextensiondatakeys/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("get") +def data_extension_get( + ctx: typer.Context, + key: Annotated[str, typer.Option("--key", help="扩展键")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据成功", + method="GET", + path="/getextensiondata/", + params={"network": require_network(runtime), "key": key}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_extension_app.command("list") +def data_extension_list(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取扩展数据列表成功", + method="GET", + path="/getallextensiondata/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_misc_app.command("sensor-placements") +def data_misc_sensor_placements(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取传感器位置成功", + method="GET", + path="/getallsensorplacements/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) + + +@data_misc_app.command("burst-location-results") +def data_misc_burst_location_results(ctx: typer.Context) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取爆管定位结果成功", + method="GET", + path="/getallburstlocateresults/", + params={"network": require_network(runtime)}, + require_auth=True, + require_network_ctx=True, + ) diff --git a/cli/tjwater_agent_cli/commands_project.py b/cli/tjwater_agent_cli/commands_project.py new file mode 100644 index 0000000..4345967 --- /dev/null +++ b/cli/tjwater_agent_cli/commands_project.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any + +import typer + +from .apps import component_option_app, network_app, project_app +from .common import emit_api, runtime_context +from .core import CLIError, emit_success, request_bytes, request_json, require_network + + +@project_app.command("list") +def project_list(ctx: typer.Context) -> None: + emit_api(ctx, summary="读取项目列表成功", method="GET", path="/meta/projects", require_auth=True) + + +@project_app.command("info") +def project_info(ctx: typer.Context) -> None: + emit_api( + ctx, + summary="读取项目信息成功", + method="GET", + path="/meta/project", + require_auth=True, + require_project=True, + ) + + +@project_app.command("db-health") +def project_db_health(ctx: typer.Context) -> None: + emit_api( + ctx, + summary="读取数据库健康状态成功", + method="GET", + path="/meta/db/health", + require_auth=True, + require_project=True, + ) + + +@project_app.command("data") +def project_data( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="scada-info|scheme-list|burst-locate-result")], +) -> None: + kind_map = { + "scada-info": "/scada-info", + "scheme-list": "/scheme-list", + "burst-locate-result": "/burst-locate-result", + } + path = kind_map.get(kind) + if path is None: + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: scada-info, scheme-list, burst-locate-result", + exit_code=2, + ) + emit_api( + ctx, + summary="读取项目数据成功", + method="GET", + path=path, + require_auth=True, + require_project=True, + ) + + +@project_app.command("export-inp") +def project_export_inp( + ctx: typer.Context, + output: Annotated[Path, typer.Option("--output", help="本地输出路径")], +) -> None: + runtime = runtime_context(ctx) + network = require_network(runtime) + output.parent.mkdir(parents=True, exist_ok=True) + temp_name = f"{output.stem}-{runtime.request_id}.inp" + _, duration_dump = request_json( + runtime, + method="GET", + path="/dumpinp/", + params={"network": network, "inp": temp_name}, + require_auth=True, + require_network_ctx=True, + ) + content, duration_download = request_bytes( + runtime, + method="GET", + path="/downloadinp/", + params={"name": temp_name}, + require_auth=True, + require_network_ctx=True, + ) + output.write_bytes(content) + emit_success( + summary="导出 INP 成功", + data={"output": str(output), "bytes": len(content)}, + ctx=runtime, + duration_ms=duration_dump + duration_download, + next_commands=["tjwater project info"], + ) + + +@network_app.command("get-node-properties") +def network_get_node_properties( + ctx: typer.Context, + node: Annotated[str, typer.Option("--node", help="节点 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取节点属性成功", + method="GET", + path="/getnodeproperties/", + params={"network": require_network(runtime), "node": node}, + require_auth=True, + require_network_ctx=True, + ) + + +@network_app.command("get-link-properties") +def network_get_link_properties( + ctx: typer.Context, + link: Annotated[str, typer.Option("--link", help="管线 ID")], +) -> None: + runtime = runtime_context(ctx) + emit_api( + ctx, + summary="读取管线属性成功", + method="GET", + path="/getlinkproperties/", + params={"network": require_network(runtime), "link": link}, + require_auth=True, + require_network_ctx=True, + ) + + +def _component_option_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: + if kind == "time": + return "/gettimeschema", {} + if kind == "energy": + return "/getenergyschema/", {} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + return "/getpumpenergyschema/", {"pump": pump} + if kind == "network": + return "/getoptionschema/", {} + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: time, energy, pump-energy, network", + exit_code=2, + ) + + +def _component_option_get_mapping(kind: str, pump: str | None) -> tuple[str, dict[str, Any]]: + if kind == "time": + return "/gettimeproperties/", {} + if kind == "energy": + return "/getenergyproperties/", {} + if kind == "pump-energy": + if not pump: + raise CLIError( + "CLI 参数错误", + code="PUMP_REQUIRED", + message="--pump is required when --kind pump-energy", + exit_code=2, + ) + return "/getpumpenergyproperties/", {"pump": pump} + if kind == "network": + return "/getoptionproperties/", {} + raise CLIError( + "CLI 参数错误", + code="INVALID_KIND", + message="kind must be one of: time, energy, pump-energy, network", + exit_code=2, + ) + + +@component_option_app.command("schema") +def component_option_schema( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path, extra = _component_option_mapping(kind, pump) + params = {"network": require_network(runtime)} | extra + emit_api( + ctx, + summary="读取选项 schema 成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) + + +@component_option_app.command("get") +def component_option_get( + ctx: typer.Context, + kind: Annotated[str, typer.Option("--kind", help="time|energy|pump-energy|network")], + pump: Annotated[str | None, typer.Option("--pump", help="泵 ID")] = None, +) -> None: + runtime = runtime_context(ctx) + path, extra = _component_option_get_mapping(kind, pump) + params = {"network": require_network(runtime)} | extra + emit_api( + ctx, + summary="读取选项属性成功", + method="GET", + path=path, + params=params, + require_auth=True, + require_network_ctx=True, + ) diff --git a/cli/tjwater_agent_cli/common.py b/cli/tjwater_agent_cli/common.py new file mode 100644 index 0000000..b03624c --- /dev/null +++ b/cli/tjwater_agent_cli/common.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer + +from .core import DEFAULT_TIMEOUT, build_runtime_context, emit_success, request_json + + +def runtime_context(ctx: typer.Context): + obj = ctx.obj or {} + return build_runtime_context( + server=obj.get("server"), + auth_context_path=obj.get("auth_context"), + scheme=obj.get("scheme"), + timeout=obj.get("timeout", DEFAULT_TIMEOUT), + request_id=obj.get("request_id"), + ) + + +def emit_api( + ctx: typer.Context, + *, + summary: str, + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: Any = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, + require_username_ctx: bool = False, + next_commands: list[str] | None = None, +) -> None: + runtime = runtime_context(ctx) + data, duration_ms = request_json( + runtime, + method=method, + path=path, + params=params, + json_body=json_body, + require_auth=require_auth, + require_project=require_project, + require_network_ctx=require_network_ctx, + require_username_ctx=require_username_ctx, + ) + emit_success( + summary=summary, + data=data, + ctx=runtime, + duration_ms=duration_ms, + next_commands=next_commands, + ) diff --git a/cli/tjwater_agent_cli/core.py b/cli/tjwater_agent_cli/core.py new file mode 100644 index 0000000..1881042 --- /dev/null +++ b/cli/tjwater_agent_cli/core.py @@ -0,0 +1,647 @@ +from __future__ import annotations + +import json +import os +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +import requests +import typer + +SCHEMA_VERSION = "tjwater-cli/v1" +DEFAULT_TIMEOUT = 60 +DEFAULT_SERVER = "http://192.168.1.114:8000" + + +class CLIError(Exception): + def __init__( + self, + summary: str, + *, + code: str, + message: str, + exit_code: int, + retryable: bool = False, + next_commands: list[str] | None = None, + data: Any = None, + ) -> None: + super().__init__(message) + self.summary = summary + self.code = code + self.message = message + self.exit_code = exit_code + self.retryable = retryable + self.next_commands = next_commands or [] + self.data = data + + +@dataclass(frozen=True) +class AuthContext: + server: str | None = None + access_token: str | None = None + project_id: str | None = None + user_id: str | None = None + username: str | None = None + network: str | None = None + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RuntimeContext: + server: str | None + auth: AuthContext + scheme: str | None + timeout: int + request_id: str + + +@dataclass(frozen=True) +class CommandOptionDoc: + name: str + description: str + required: bool = False + repeated: bool = False + default: Any = None + + +@dataclass(frozen=True) +class CommandDoc: + path: tuple[str, ...] + summary: str + description: str + options: tuple[CommandOptionDoc, ...] = () + examples: tuple[str, ...] = () + next_commands: tuple[str, ...] = () + output: str = "标准 JSON 输出" + + +def _read_json_file(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_NOT_FOUND", + message=f"auth context file not found: {path}", + exit_code=3, + ) from exc + except json.JSONDecodeError as exc: + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_INVALID", + message=f"auth context file is not valid JSON: {path}", + exit_code=3, + ) from exc + + +def _pick(mapping: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + value = mapping.get(key) + if value not in (None, ""): + return value + return None + + +def load_auth_context(auth_context_path: Path | None) -> AuthContext: + raw: dict[str, Any] = {} + if auth_context_path is not None: + raw = _read_json_file(auth_context_path) + else: + extra_headers = os.getenv("TJWATER_EXTRA_HEADERS") + raw = { + "server": os.getenv("TJWATER_SERVER"), + "access_token": os.getenv("TJWATER_ACCESS_TOKEN"), + "project_id": os.getenv("TJWATER_PROJECT_ID"), + "user_id": os.getenv("TJWATER_USER_ID"), + "username": os.getenv("TJWATER_USERNAME"), + "network": os.getenv("TJWATER_NETWORK"), + "headers": json.loads(extra_headers) if extra_headers else {}, + } + + headers = raw.get("headers") or {} + if not isinstance(headers, dict): + raise CLIError( + "认证失败", + code="AUTH_CONTEXT_INVALID", + message="auth context headers must be a JSON object", + exit_code=3, + ) + + return AuthContext( + server=_pick(raw, "server", "base_url"), + access_token=_pick(raw, "access_token", "token", "accessToken"), + project_id=_pick(raw, "project_id", "projectId", "x_project_id"), + user_id=_pick(raw, "user_id", "userId", "x_user_id"), + username=_pick(raw, "username", "preferred_username"), + network=_pick(raw, "network", "project_code", "projectCode", "project"), + headers={str(key): str(value) for key, value in headers.items()}, + ) + + +def build_runtime_context( + *, + server: str | None, + auth_context_path: Path | None, + scheme: str | None, + timeout: int, + request_id: str | None, +) -> RuntimeContext: + auth = load_auth_context(auth_context_path) + resolved_request_id = request_id or str(uuid.uuid4()) + return RuntimeContext( + server=server or auth.server or DEFAULT_SERVER, + auth=auth, + scheme=scheme, + timeout=timeout, + request_id=resolved_request_id, + ) + + +def require_server(ctx: RuntimeContext) -> str: + if ctx.server: + return ctx.server.rstrip("/") + raise CLIError( + "认证失败", + code="SERVER_REQUIRED", + message="missing server URL; use --server or include server in auth context", + exit_code=3, + ) + + +def require_access_token(ctx: RuntimeContext) -> str: + if ctx.auth.access_token: + return ctx.auth.access_token + raise CLIError( + "认证失败", + code="UNAUTHENTICATED", + message="missing access token for agent context", + exit_code=3, + next_commands=["tjwater --auth-context /path/to/auth-context.json"], + ) + + +def require_project_id(ctx: RuntimeContext) -> str: + if ctx.auth.project_id: + return ctx.auth.project_id + raise CLIError( + "认证失败", + code="PROJECT_CONTEXT_REQUIRED", + message="missing project_id for agent context", + exit_code=3, + next_commands=["add project_id to the auth context file"], + ) + + +def require_network(ctx: RuntimeContext) -> str: + if ctx.auth.network: + return ctx.auth.network + raise CLIError( + "认证失败", + code="NETWORK_CONTEXT_REQUIRED", + message="missing network in auth context for legacy network-based endpoints", + exit_code=3, + next_commands=["add network to the auth context file"], + ) + + +def require_username(ctx: RuntimeContext) -> str: + if ctx.auth.username: + return ctx.auth.username + raise CLIError( + "认证失败", + code="USERNAME_CONTEXT_REQUIRED", + message="missing username in auth context", + exit_code=3, + next_commands=["add username to the auth context file"], + ) + + +def resolve_scheme(ctx: RuntimeContext, explicit_scheme: str | None, *, required: bool = False) -> str | None: + scheme = explicit_scheme or ctx.scheme + if required and not scheme: + raise CLIError( + "CLI 参数错误", + code="SCHEME_REQUIRED", + message="missing scheme; use --scheme", + exit_code=2, + ) + return scheme + + +def parse_time_with_timezone(value: str, *, option_name: str) -> datetime: + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise CLIError( + "CLI 参数错误", + code="INVALID_TIME", + message=f"{option_name} must be a valid ISO 8601 / RFC 3339 timestamp", + exit_code=2, + ) from exc + if parsed.tzinfo is None: + raise CLIError( + "CLI 参数错误", + code="TIMEZONE_REQUIRED", + message=f"{option_name} must include an explicit timezone offset", + exit_code=2, + ) + return parsed + + +def read_json_input(path: Path, *, label: str) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CLIError( + "CLI 参数错误", + code="INPUT_NOT_FOUND", + message=f"{label} file not found: {path}", + exit_code=2, + ) from exc + except json.JSONDecodeError as exc: + raise CLIError( + "CLI 参数错误", + code="INPUT_INVALID_JSON", + message=f"{label} file must be valid JSON: {path}", + exit_code=2, + ) from exc + + +def parse_burst_file(path: Path) -> tuple[list[str], list[float]]: + raw = read_json_input(path, label="burst") + if isinstance(raw, dict) and "bursts" in raw: + raw = raw["bursts"] + if isinstance(raw, dict) and "burst_ID" in raw and "burst_size" in raw: + ids = [str(item) for item in raw["burst_ID"]] + sizes = [float(item) for item in raw["burst_size"]] + if len(ids) != len(sizes): + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file burst_ID and burst_size must have the same length", + exit_code=2, + ) + return ids, sizes + if isinstance(raw, list): + ids: list[str] = [] + sizes: list[float] = [] + for item in raw: + if not isinstance(item, dict) or "id" not in item or "size" not in item: + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file items must contain id and size", + exit_code=2, + ) + ids.append(str(item["id"])) + sizes.append(float(item["size"])) + return ids, sizes + raise CLIError( + "CLI 参数错误", + code="BURST_FILE_INVALID", + message="burst file must be a JSON array or object with burst_ID/burst_size", + exit_code=2, + ) + + +def parse_valve_setting_file(path: Path) -> tuple[list[str], list[float]]: + raw = read_json_input(path, label="valve-setting") + if isinstance(raw, dict) and "valves" in raw and "valves_k" in raw: + valves = [str(item) for item in raw["valves"]] + openings = [float(item) for item in raw["valves_k"]] + if len(valves) != len(openings): + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valves and valves_k must have the same length", + exit_code=2, + ) + return valves, openings + if isinstance(raw, list): + valves: list[str] = [] + openings: list[float] = [] + for item in raw: + if not isinstance(item, dict) or "valve" not in item or "opening" not in item: + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valve-setting items must contain valve and opening", + exit_code=2, + ) + valves.append(str(item["valve"])) + openings.append(float(item["opening"])) + return valves, openings + raise CLIError( + "CLI 参数错误", + code="VALVE_SETTING_INVALID", + message="valve-setting file must be a JSON array or object with valves/valves_k", + exit_code=2, + ) + + +def parse_optional_dataset_file(path: Path | None, *, label: str) -> Any: + if path is None: + return None + return read_json_input(path, label=label) + + +def build_headers( + ctx: RuntimeContext, + *, + require_auth: bool, + require_project: bool, +) -> dict[str, str]: + headers = { + "Accept": "application/json, text/plain, */*", + "X-Request-Id": ctx.request_id, + } + headers.update(ctx.auth.headers) + if require_auth: + headers["Authorization"] = f"Bearer {require_access_token(ctx)}" + elif ctx.auth.access_token: + headers["Authorization"] = f"Bearer {ctx.auth.access_token}" + if require_project: + headers["X-Project-Id"] = require_project_id(ctx) + elif ctx.auth.project_id: + headers["X-Project-Id"] = ctx.auth.project_id + if ctx.auth.user_id: + headers["X-User-Id"] = ctx.auth.user_id + return headers + + +def _extract_error_message(response: requests.Response) -> str: + try: + payload = response.json() + except ValueError: + text = response.text.strip() + return text or f"http {response.status_code}" + + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str): + return detail + if isinstance(detail, list): + return "; ".join(json.dumps(item, ensure_ascii=False) for item in detail) + message = payload.get("message") + if isinstance(message, str): + return message + return json.dumps(payload, ensure_ascii=False) + + +def map_http_status_to_exit_code(status_code: int) -> int: + if status_code in (400, 422): + return 2 + if status_code == 401: + return 3 + if status_code == 403: + return 4 + if status_code == 404: + return 5 + if status_code in (409, 412): + return 6 + return 7 + + +def _parse_response_body(response: requests.Response) -> Any: + if response.status_code == 204 or not response.content: + return {} + content_type = response.headers.get("content-type", "").lower() + if "application/json" in content_type: + payload = response.json() + if isinstance(payload, dict) and payload.get("status") == "error": + raise CLIError( + "服务端错误", + code="SERVER_ERROR", + message=str(payload.get("message") or "server returned error status"), + exit_code=7, + data=payload, + ) + return payload + text = response.text + if text: + return {"report": text} + return {} + + +def request_json( + ctx: RuntimeContext, + *, + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: Any = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, + require_username_ctx: bool = False, +) -> tuple[Any, int]: + require_server(ctx) + if require_network_ctx: + require_network(ctx) + if require_username_ctx: + require_username(ctx) + + url = f"{require_server(ctx)}/api/v1{path}" + headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) + started = time.monotonic() + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + json=json_body, + headers=headers, + timeout=ctx.timeout, + ) + except requests.Timeout as exc: + raise CLIError( + "请求超时", + code="REQUEST_TIMEOUT", + message=f"request timed out after {ctx.timeout} seconds", + exit_code=7, + retryable=True, + ) from exc + except requests.RequestException as exc: + raise CLIError( + "连接失败", + code="REQUEST_FAILED", + message=str(exc), + exit_code=7, + retryable=True, + ) from exc + duration_ms = int((time.monotonic() - started) * 1000) + + if not response.ok: + raise CLIError( + "请求失败", + code=f"HTTP_{response.status_code}", + message=_extract_error_message(response), + exit_code=map_http_status_to_exit_code(response.status_code), + retryable=response.status_code >= 500, + ) + return _parse_response_body(response), duration_ms + + +def request_bytes( + ctx: RuntimeContext, + *, + method: str, + path: str, + params: dict[str, Any] | None = None, + require_auth: bool = True, + require_project: bool = False, + require_network_ctx: bool = False, +) -> tuple[bytes, int]: + require_server(ctx) + if require_network_ctx: + require_network(ctx) + + url = f"{require_server(ctx)}/api/v1{path}" + headers = build_headers(ctx, require_auth=require_auth, require_project=require_project) + started = time.monotonic() + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + headers=headers, + timeout=ctx.timeout, + ) + except requests.Timeout as exc: + raise CLIError( + "请求超时", + code="REQUEST_TIMEOUT", + message=f"request timed out after {ctx.timeout} seconds", + exit_code=7, + retryable=True, + ) from exc + except requests.RequestException as exc: + raise CLIError( + "连接失败", + code="REQUEST_FAILED", + message=str(exc), + exit_code=7, + retryable=True, + ) from exc + duration_ms = int((time.monotonic() - started) * 1000) + + if not response.ok: + raise CLIError( + "请求失败", + code=f"HTTP_{response.status_code}", + message=_extract_error_message(response), + exit_code=map_http_status_to_exit_code(response.status_code), + retryable=response.status_code >= 500, + ) + return response.content, duration_ms + + +def build_success_payload( + *, + summary: str, + data: Any, + server: str | None, + request_id: str, + duration_ms: int, + next_commands: list[str] | None = None, +) -> dict[str, Any]: + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": summary, + "data": data, + "metadata": { + "request_id": request_id, + "server": server, + "duration_ms": duration_ms, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + }, + "next_commands": next_commands or [], + } + + +def build_failure_payload( + *, + summary: str, + code: str, + message: str, + retryable: bool, + server: str | None, + request_id: str | None, + next_commands: list[str] | None = None, + data: Any = None, +) -> dict[str, Any]: + return { + "ok": False, + "schema_version": SCHEMA_VERSION, + "summary": summary, + "error": { + "code": code, + "message": message, + "retryable": retryable, + }, + "data": data, + "metadata": { + "request_id": request_id, + "server": server, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + }, + "next_commands": next_commands or [], + } + + +def emit_success( + *, + summary: str, + data: Any, + ctx: RuntimeContext, + duration_ms: int, + next_commands: list[str] | None = None, +) -> None: + typer.echo( + json.dumps( + build_success_payload( + summary=summary, + data=data, + server=ctx.server, + request_id=ctx.request_id, + duration_ms=duration_ms, + next_commands=next_commands, + ), + ensure_ascii=False, + ) + ) + + +def emit_failure( + *, + summary: str, + code: str, + message: str, + exit_code: int, + retryable: bool, + server: str | None, + request_id: str | None, + next_commands: list[str] | None = None, + data: Any = None, +) -> int: + typer.echo( + json.dumps( + build_failure_payload( + summary=summary, + code=code, + message=message, + retryable=retryable, + server=server, + request_id=request_id, + next_commands=next_commands, + data=data, + ), + ensure_ascii=False, + ) + ) + return exit_code diff --git a/cli/tjwater_agent_cli/helping.py b/cli/tjwater_agent_cli/helping.py new file mode 100644 index 0000000..1461fd4 --- /dev/null +++ b/cli/tjwater_agent_cli/helping.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from typing import Annotated, Any + +import click +import typer + +from .apps import GROUP_HELP_APPS, TOP_LEVEL_COMMANDS, app +from .core import CLIError +from .registry import ( + get_command_doc, + get_group_summary, + has_subcommands, + is_hidden_path, + list_capabilities, + list_subcommands, +) + + +def _click_root_command() -> click.Command: + # Must stay lazy: the click tree is only complete after command modules import. + return typer.main.get_command(app) + + +def _normalize_command_path(tokens: list[str]) -> tuple[str, ...]: + while tokens and tokens[0] not in TOP_LEVEL_COMMANDS: + tokens = tokens[1:] + return tuple(tokens) + + +def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]: + if click_ctx is None: + return () + return _normalize_command_path(click_ctx.command_path.split()) + + +def _build_click_context(path: tuple[str, ...]) -> click.Context | None: + root = _click_root_command() + ctx: click.Context = click.Context(root, info_name="tjwater") + command: click.Command = root + for token in path: + if not isinstance(command, click.Group): + return None + next_command = command.commands.get(token) + if next_command is None: + return None + ctx = click.Context(next_command, info_name=token, parent=ctx) + command = next_command + return ctx + + +def build_usage(path: tuple[str, ...]) -> str | None: + ctx = _build_click_context(path) + if ctx is None: + return None + parts = ["tjwater", *path] + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts: + continue + option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + if parameter.is_flag: + parts.append(f"--{option_name}" if parameter.required else f"[--{option_name}]") + continue + placeholder = option_name.upper().replace("-", "_") + if parameter.required: + parts.extend([f"--{option_name}", f"<{placeholder}>"]) + else: + parts.append(f"[--{option_name} <{placeholder}>]") + return " ".join(parts) + + +def _click_option_docs(path: tuple[str, ...]) -> list[dict[str, Any]]: + ctx = _build_click_context(path) + if ctx is None: + return [] + options: list[dict[str, Any]] = [] + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts: + continue + cli_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + options.append( + { + "name": cli_name, + "description": parameter.help or "", + "required": parameter.required, + "repeated": parameter.multiple, + "default": parameter.default, + } + ) + return options + + +def _sample_option_value(path: tuple[str, ...], option_name: str) -> str: + path_specific_samples: dict[tuple[tuple[str, ...], str], str] = { + (("project", "data"), "kind"): "scada-info", + (("component", "option", "schema"), "kind"): "time", + (("component", "option", "get"), "kind"): "time", + (("data", "timeseries", "composite"), "kind"): "scada-simulation", + (("data", "scada", "schema"), "kind"): "device", + (("data", "scada", "get"), "kind"): "device", + (("data", "scada", "list"), "kind"): "device", + } + if (path, option_name) in path_specific_samples: + return path_specific_samples[(path, option_name)] + if option_name == "start-time": + return "2025-01-02T03:04:05+08:00" + if option_name == "end-time": + return "2025-01-02T04:04:05+08:00" + if option_name == "date": + return "2025-01-02" + if option_name == "duration": + return "30" + if option_name == "kind": + return "time" + if option_name == "mode": + return "close" + if option_name == "scheme": + return "baseline" + if option_name == "output": + return "./demo.inp" if "export-inp" in path else "./output.json" + if option_name == "pump": + return "PUMP-1" + if option_name == "node": + return "J1" + if option_name == "source-node": + return "J1" + if option_name == "drainage-node": + return "J2" + if option_name in {"link", "pipe", "pipe-id", "element-id", "element"}: + return "P1" + if option_name == "flow": + return "120.5" + if option_name == "concentration": + return "0.8" + if option_name == "device-id": + return "SCADA-001" + if option_name == "burst-file": + return "./burst.json" + if option_name == "valve-setting-file": + return "./valves.json" + if option_name.endswith("-file"): + return "./input.json" + if option_name.endswith("-id"): + return "demo-id" + return "demo" + + +def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None = None) -> str: + ctx = _build_click_context(path) + required_option_names: list[str] = [] + if ctx is not None: + required_option_names = [ + next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + for parameter in ctx.command.params + if isinstance(parameter, click.Option) and "--help" not in parameter.opts and parameter.required + ] + if existing_examples: + for example in existing_examples: + has_auth = "--auth-context" in example + 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] + if ctx is None: + return " ".join(parts) + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option): + continue + if "--help" in parameter.opts or not parameter.required: + continue + option_name = next((opt.lstrip("-") for opt in reversed(parameter.opts) if opt.startswith("--")), parameter.name or "") + parts.extend([f"--{option_name}", _sample_option_value(path, option_name)]) + return " ".join(parts) + + +def _enrich_leaf_payload(payload: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]: + enriched = dict(payload) + enriched["usage"] = build_usage(path) or payload.get("usage") + click_options = _click_option_docs(path) + if click_options: + enriched["options"] = click_options + enriched["examples"] = payload.get("examples") or [] + if not enriched["examples"] or all("<" in example and ">" in example for example in enriched["examples"]): + enriched["examples"] = [_build_example(path, existing_examples=enriched["examples"])] + return enriched + + +def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + enriched = dict(payload) + commands: list[dict[str, Any]] = [] + for command in payload.get("commands", []): + command_item = dict(command) + 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" + else: + existing_examples = [] if doc is None else list(doc.get("examples", [])) + command_item["usage"] = build_usage(path) or command_item.get("usage") + command_item["example"] = _build_example(path, existing_examples=existing_examples) + commands.append(command_item) + enriched["commands"] = commands + return enriched + + +def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, bool]: + if not path: + return list_capabilities(), True + payload = get_command_doc(path) + if payload is not None: + return _enrich_leaf_payload(payload, path), False + if has_subcommands(path): + return _enrich_index_payload(list_subcommands(path, get_group_summary(path))), True + 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 merge_next_commands(*groups: list[str] | None) -> list[str]: + merged: list[str] = [] + seen: set[str] = set() + for group in groups: + for command in group or []: + if command in seen: + continue + seen.add(command) + merged.append(command) + return merged + + +def merge_error_data(primary: Any, secondary: Any) -> Any: + if primary is None: + return secondary + if secondary is None: + return primary + if isinstance(primary, dict) and isinstance(secondary, dict): + return {**secondary, **primary} + return primary + + +def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str]]: + command_path = context_command_path(click_ctx) + usage = build_usage(command_path) if command_path else None + if command_path: + if command_path[-1] == "help": + group_path = command_path[:-1] + if group_path: + 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)}"], + }, + merge_next_commands( + [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], + ["tjwater help"], + ), + ) + payload, is_index = resolve_help_payload(command_path) + if payload is not None and not is_index: + return ( + { + "command": payload["command"], + "usage": payload.get("usage") or usage, + "examples": payload.get("examples", []), + }, + merge_next_commands([f"tjwater help {' '.join(command_path)}"], ["tjwater 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)}"], + }, + merge_next_commands( + [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], + ["tjwater help"], + ), + ) + return ({"usage": usage} if usage else None, ["tjwater help"]) + + +def classify_click_error(exc: click.ClickException) -> tuple[str, str]: + if isinstance(exc, click.NoSuchOption): + return "未知选项", "UNKNOWN_OPTION" + if isinstance(exc, click.MissingParameter): + return "缺少参数", "MISSING_PARAMETER" + if isinstance(exc, click.BadParameter): + return "参数无效", "INVALID_PARAMETER" + message = exc.format_message() + if "No such command" in message: + return "未找到命令", "COMMAND_NOT_FOUND" + 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 help` to see subcommands.") + else: + lines.append("Use `tjwater help --json` for structured output.") + return "\n".join(lines) + + 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']}") + + examples = payload.get("examples", []) + 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.") + return "\n".join(lines) + + +def make_group_help_handler(path_prefix: tuple[str, ...]): + def group_help( + json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, + ) -> None: + payload, is_index = resolve_help_payload(path_prefix) + if payload is None: + raise CLIError( + "未找到命令", + code="COMMAND_NOT_FOUND", + message=f"unknown command path: {' '.join(path_prefix)}", + exit_code=2, + next_commands=["tjwater help"], + ) + emit_help_payload(payload, json_output=json_output, is_index=is_index) + + group_help.__name__ = f"{'_'.join(path_prefix)}_help" + return group_help + + +def register_group_help_commands() -> None: + for group_app, path_prefix in GROUP_HELP_APPS: + group_app.command("help")(make_group_help_handler(path_prefix)) + + +def apply_typer_help_metadata() -> None: + app.help = "TJWater agent CLI" + 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.short_help = command_info.help + 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.hidden = is_hidden_path(command_path) + for group_info in group_app.registered_groups: + group_path = (*path_prefix, group_info.name) + summary = get_group_summary(group_path) + group_info.help = summary + group_info.short_help = summary + group_info.hidden = is_hidden_path(group_path) + for group_info in app.registered_groups: + group_path = (group_info.name,) + summary = get_group_summary(group_path) + group_info.help = summary + group_info.short_help = summary + group_info.hidden = is_hidden_path(group_path) diff --git a/cli/tjwater_agent_cli/main.py b/cli/tjwater_agent_cli/main.py new file mode 100644 index 0000000..ebc3d14 --- /dev/null +++ b/cli/tjwater_agent_cli/main.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Annotated + +import click +import typer +from click.exceptions import NoArgsIsHelpError + +from . import commands_analysis, commands_data, commands_project # noqa: F401 +from .apps import app +from .core import CLIError, DEFAULT_SERVER, DEFAULT_TIMEOUT, emit_failure +from .helping import ( + apply_typer_help_metadata, + build_error_guidance, + classify_click_error, + emit_help_payload, + merge_error_data, + merge_next_commands, + register_group_help_commands, + resolve_help_payload, +) + + +@app.callback() +def root_callback( + ctx: typer.Context, + server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None, + auth_context: Annotated[Path | None, typer.Option("--auth-context", help="认证上下文 JSON 文件")] = None, + scheme: Annotated[str | None, typer.Option("--scheme", help="全局方案标识")] = None, + timeout: Annotated[int, typer.Option("--timeout", help="请求超时秒数")] = DEFAULT_TIMEOUT, + request_id: Annotated[str | None, typer.Option("--request-id", help="显式请求 ID")] = None, +) -> None: + ctx.obj = { + "server": server, + "auth_context": auth_context, + "scheme": scheme, + "timeout": timeout, + "request_id": request_id, + } + + +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: + command_path = list(ctx.args) + payload, is_index = resolve_help_payload(tuple(command_path)) + if payload is None: + emit_failure( + summary="未找到命令", + code="COMMAND_NOT_FOUND", + message=f"unknown command path: {' '.join(command_path)}", + exit_code=2, + retryable=False, + server=None, + request_id=None, + data={ + "usage": "tjwater help ", + "examples": ["tjwater help simulation run", "tjwater simulation help"], + }, + next_commands=["tjwater help", "tjwater help simulation"], + ) + raise typer.Exit(code=2) + emit_help_payload(payload, json_output=json_output, is_index=is_index) + + +# Must run at import time because tests call runner.invoke(app, ...) directly. +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) + return 0 + except CLIError as exc: + click_ctx = click.get_current_context(silent=True) + error_data, next_commands = build_error_guidance(click_ctx) + return emit_failure( + summary=exc.summary, + code=exc.code, + message=exc.message, + exit_code=exc.exit_code, + retryable=exc.retryable, + server=None, + request_id=None, + next_commands=merge_next_commands(exc.next_commands, next_commands), + data=merge_error_data(exc.data, error_data), + ) + except NoArgsIsHelpError: + return 0 + except click.ClickException as exc: + click_ctx = click.get_current_context(silent=True) or exc.ctx + error_data, next_commands = build_error_guidance(click_ctx) + summary, code = classify_click_error(exc) + return emit_failure( + summary=summary, + code=code, + message=exc.format_message(), + exit_code=2, + retryable=False, + server=None, + request_id=None, + next_commands=next_commands, + data=error_data, + ) + + +def console_entry() -> None: + raise SystemExit(main()) diff --git a/cli/tjwater_agent_cli/registry.py b/cli/tjwater_agent_cli/registry.py new file mode 100644 index 0000000..d83d782 --- /dev/null +++ b/cli/tjwater_agent_cli/registry.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION + +GROUP_SUMMARIES: dict[tuple[str, ...], str] = { + ("project",): "项目与项目级元数据相关命令。", + ("network",): "管网节点、管线等基础属性查询命令。", + ("component",): "组件选项与配置读取命令。", + ("component", "option"): "组件选项查询命令。", + ("simulation",): "模拟运行与调度相关命令。", + ("analysis",): "分析计算与诊断相关命令。", + ("analysis", "leakage"): "漏损分析相关命令。", + ("analysis", "leakage", "schemes"): "漏损方案查询命令。", + ("analysis", "burst-detection"): "爆管检测相关命令。", + ("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。", + ("analysis", "burst-location"): "爆管定位相关命令。", + ("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。", + ("analysis", "risk"): "风险分析相关命令。", + ("analysis", "sensor-placement"): "传感器选址相关命令。", + ("data",): "时序、SCADA、方案和扩展数据查询命令。", + ("data", "timeseries"): "时序数据查询命令。", + ("data", "timeseries", "realtime"): "实时模拟时序查询命令。", + ("data", "timeseries", "scheme"): "方案时序查询命令。", + ("data", "timeseries", "scada"): "SCADA 时序查询命令。", + ("data", "timeseries", "composite"): "复合时序查询命令。", + ("data", "scada"): "SCADA 元数据查询命令。", + ("data", "scheme"): "方案数据查询命令。", + ("data", "extension"): "扩展数据查询命令。", + ("data", "misc"): "其他结果数据查询命令。", +} + +HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = ( + ("analysis", "burst-location"), + ("analysis", "risk"), +) + +COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { + ("project", "list"): 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",), + output="项目摘要列表", + ), + ("project", "info"): CommandDoc( + path=("project", "info"), + summary="读取当前项目元数据", + description="调用 /meta/project 返回当前 project 详情。", + examples=("tjwater --auth-context auth.json project info",), + output="项目元数据", + ), + ("project", "db-health"): CommandDoc( + path=("project", "db-health"), + summary="检查当前项目数据库健康状态", + description="调用 /meta/db/health 返回 PostgreSQL 与 Timescale 健康状态。", + ), + ("project", "export-inp"): CommandDoc( + path=("project", "export-inp"), + summary="导出当前项目 INP 到本地文件", + description="先调用 /dumpinp/ 在服务端生成 INP,再通过 /downloadinp/ 下载到本地。", + options=( + CommandOptionDoc("output", "本地输出路径", required=True), + ), + output="本地文件路径和下载摘要", + ), + ("project", "data"): CommandDoc( + path=("project", "data"), + summary="读取当前项目业务数据", + description="kind 支持 scada-info、scheme-list、burst-locate-result。", + options=(CommandOptionDoc("kind", "数据类型", required=True),), + ), + ("network", "get-node-properties"): CommandDoc( + path=("network", "get-node-properties"), + summary="读取节点属性", + description="调用 /getnodeproperties/。", + options=(CommandOptionDoc("node", "节点 ID", required=True),), + ), + ("network", "get-link-properties"): CommandDoc( + path=("network", "get-link-properties"), + summary="读取管线属性", + description="调用 /getlinkproperties/。", + options=(CommandOptionDoc("link", "管线 ID", required=True),), + ), + ("component", "option", "schema"): CommandDoc( + path=("component", "option", "schema"), + summary="读取选项 schema", + description="kind 支持 time、energy、pump-energy、network。", + options=( + CommandOptionDoc("kind", "选项类型", required=True), + CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), + ), + ), + ("component", "option", "get"): CommandDoc( + path=("component", "option", "get"), + summary="读取选项属性", + description="kind 支持 time、energy、pump-energy、network。", + options=( + CommandOptionDoc("kind", "选项类型", required=True), + CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"), + ), + ), + ("simulation", "run"): CommandDoc( + path=("simulation", "run"), + summary="触发指定绝对时间的模拟运行", + description="把 RFC3339 start-time 拆成 simulation_date 与 start_time 后调用 /runsimulationmanuallybydate/;接口本身只负责触发运行,结果需后续通过 data timeseries 在对应时间段查询。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + 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", + ), + output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", + ), + ("analysis", "burst"): CommandDoc( + path=("analysis", "burst"), + summary="执行爆管分析", + description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。", + options=( + CommandOptionDoc("start-time", "显式带时区的开始时间", required=True), + CommandOptionDoc("duration", "持续秒数", required=True), + CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True), + 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", + ), + next_commands=( + "tjwater --auth-context auth.json data scheme get --name burst_case_01", + "tjwater --auth-context auth.json data scheme list", + ), + output="分析执行结果;方案详情需通过 data scheme 命令单独查询", + ), + ("analysis", "valve"): CommandDoc( + path=("analysis", "valve"), + 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", + ), + ), + ("analysis", "flushing"): CommandDoc( + path=("analysis", "flushing"), + summary="执行冲洗分析", + description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。", + ), + ("analysis", "age"): CommandDoc( + path=("analysis", "age"), + summary="执行水龄分析", + description="调用 /age_analysis/。", + ), + ("analysis", "contaminant"): CommandDoc( + path=("analysis", "contaminant"), + summary="执行污染物模拟", + description="调用 /contaminant_simulation/。", + ), + ("analysis", "sensor-placement", "kmeans"): CommandDoc( + path=("analysis", "sensor-placement", "kmeans"), + summary="执行 KMeans 传感器选址", + description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。", + ), + ("analysis", "leakage", "identify"): CommandDoc( + path=("analysis", "leakage", "identify"), + summary="执行漏损识别", + description="把 CLI 时间映射到 scada_start / scada_end。", + ), + ("analysis", "leakage", "schemes", "list"): CommandDoc( + path=("analysis", "leakage", "schemes", "list"), + summary="列出漏损方案", + description="调用 /leakage/schemes/。", + ), + ("analysis", "leakage", "schemes", "get"): CommandDoc( + path=("analysis", "leakage", "schemes", "get"), + summary="读取漏损方案详情", + description="调用 /leakage/schemes/{scheme_name}。", + ), + ("analysis", "burst-detection", "detect"): CommandDoc( + path=("analysis", "burst-detection", "detect"), + summary="执行爆管检测", + description="调用 /burst-detection/detect/。", + ), + ("analysis", "burst-detection", "schemes", "list"): CommandDoc( + path=("analysis", "burst-detection", "schemes", "list"), + summary="列出爆管检测方案", + description="调用 /burst-detection/schemes/。", + ), + ("analysis", "burst-detection", "schemes", "get"): CommandDoc( + path=("analysis", "burst-detection", "schemes", "get"), + summary="读取爆管检测方案详情", + description="调用 /burst-detection/schemes/{scheme_name}。", + ), + ("analysis", "burst-location", "locate"): CommandDoc( + path=("analysis", "burst-location", "locate"), + summary="执行爆管定位", + description="调用 /burst-location/locate/;需要 burst-leakage。", + ), + ("analysis", "burst-location", "schemes", "list"): CommandDoc( + path=("analysis", "burst-location", "schemes", "list"), + summary="列出爆管定位方案", + description="调用 /burst-location/schemes/。", + ), + ("analysis", "burst-location", "schemes", "get"): CommandDoc( + path=("analysis", "burst-location", "schemes", "get"), + summary="读取爆管定位方案详情", + description="调用 /burst-location/schemes/{scheme_name}。", + ), + ("analysis", "risk", "pipe-now"): CommandDoc( + path=("analysis", "risk", "pipe-now"), + summary="读取单条管道当前风险", + description="调用 /getpiperiskprobabilitynow/。", + ), + ("analysis", "risk", "pipe-history"): CommandDoc( + path=("analysis", "risk", "pipe-history"), + summary="读取单条管道历史风险", + description="调用 /getpiperiskprobability/。", + ), + ("analysis", "risk", "network"): CommandDoc( + path=("analysis", "risk", "network"), + summary="读取全网风险", + description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。", + ), + ("data", "timeseries", "realtime", "links"): CommandDoc( + path=("data", "timeseries", "realtime", "links"), + summary="查询实时管道时序", + description="调用 /realtime/links。", + ), + ("data", "timeseries", "realtime", "nodes"): CommandDoc( + path=("data", "timeseries", "realtime", "nodes"), + summary="查询实时节点时序", + description="调用 /realtime/nodes。", + ), + ("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc( + path=("data", "timeseries", "realtime", "simulation-by-id-time"), + summary="按元素和时间查询实时模拟结果", + description="调用 /realtime/query/by-id-time。", + ), + ("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc( + path=("data", "timeseries", "realtime", "simulation-by-time-property"), + summary="按时间和属性查询实时模拟结果", + description="调用 /realtime/query/by-time-property。", + ), + ("data", "timeseries", "scheme", "links"): CommandDoc( + path=("data", "timeseries", "scheme", "links"), + summary="查询方案管道时序", + description="调用 /scheme/links。", + ), + ("data", "timeseries", "scheme", "node-field"): CommandDoc( + path=("data", "timeseries", "scheme", "node-field"), + summary="查询方案节点字段时序", + description="调用 /scheme/nodes/{node_id}/field。", + ), + ("data", "timeseries", "scheme", "simulation"): CommandDoc( + path=("data", "timeseries", "scheme", "simulation"), + summary="查询方案模拟数据", + description="支持 by-id-time 与 by-scheme-time-property 两种查询。", + ), + ("data", "timeseries", "scada", "query"): CommandDoc( + path=("data", "timeseries", "scada", "query"), + summary="查询 SCADA 时序", + description="device-id 会被转换成后端逗号分隔参数。", + ), + ("data", "timeseries", "composite"): CommandDoc( + path=("data", "timeseries", "composite"), + summary="执行复合时序查询", + description="kind 支持 scada-simulation、element-simulation、element-scada。", + ), + ("data", "timeseries", "composite", "pipeline-health"): CommandDoc( + path=("data", "timeseries", "composite", "pipeline-health"), + summary="查询管道健康预测", + description="调用 /composite/pipeline-health-prediction。", + ), + ("data", "scada", "schema"): CommandDoc( + path=("data", "scada", "schema"), + summary="读取 SCADA schema", + description="kind 支持 device、device-data、element、info。", + ), + ("data", "scada", "get"): CommandDoc( + path=("data", "scada", "get"), + summary="读取单条 SCADA 元数据", + description="kind 支持 device、device-data、element、info。", + ), + ("data", "scada", "list"): CommandDoc( + path=("data", "scada", "list"), + summary="列出 SCADA 元数据", + description="kind 支持 device、element、info;device-data 当前后端无 list 接口。", + ), + ("data", "scheme", "schema"): CommandDoc( + path=("data", "scheme", "schema"), + summary="读取方案 schema", + description="调用 /getschemeschema/。", + ), + ("data", "scheme", "get"): CommandDoc( + path=("data", "scheme", "get"), + summary="读取单条方案", + description="调用 /getscheme/。", + ), + ("data", "scheme", "list"): CommandDoc( + path=("data", "scheme", "list"), + summary="列出方案", + description="调用 /getallschemes/。", + ), + ("data", "extension", "keys"): CommandDoc( + path=("data", "extension", "keys"), + summary="列出扩展数据键", + description="调用 /getallextensiondatakeys/。", + ), + ("data", "extension", "get"): CommandDoc( + path=("data", "extension", "get"), + summary="读取扩展数据", + description="调用 /getextensiondata/。", + ), + ("data", "extension", "list"): CommandDoc( + path=("data", "extension", "list"), + summary="列出扩展数据", + description="调用 /getallextensiondata/。", + ), + ("data", "misc", "sensor-placements"): CommandDoc( + path=("data", "misc", "sensor-placements"), + summary="列出传感器布置结果", + description="调用 /getallsensorplacements/。", + ), + ("data", "misc", "burst-location-results"): CommandDoc( + path=("data", "misc", "burst-location-results"), + summary="列出爆管定位结果", + description="调用 /getallburstlocateresults/。", + ), +} + + +def _build_examples(doc: CommandDoc) -> list[str]: + return list(doc.examples) if doc.examples else [_build_usage(doc)] + + +def _is_hidden_path(path: tuple[str, ...]) -> bool: + return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES) + + +def is_hidden_path(path: tuple[str, ...]) -> bool: + return _is_hidden_path(path) + + +def has_subcommands(path_prefix: tuple[str, ...]) -> bool: + return any( + not _is_hidden_path(doc.path) + and doc.path[: len(path_prefix)] == path_prefix + and len(doc.path) > len(path_prefix) + for doc in COMMAND_DOCS.values() + ) + + +def get_group_summary(path_prefix: tuple[str, ...]) -> str: + return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令") + + +def list_capabilities() -> dict[str, object]: + seen: set[tuple[str, ...]] = set() + commands: list[dict[str, str]] = [] + for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): + if _is_hidden_path(doc.path): + continue + prefix = doc.path[:1] + if prefix in seen: + continue + seen.add(prefix) + commands.append( + { + "command": " ".join(prefix), + "summary": get_group_summary(prefix), + } + ) + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": "可用一级菜单", + "menu_level": 1, + "commands": commands, + } + + +def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None: + if _is_hidden_path(path): + return None + doc = COMMAND_DOCS.get(path) + if doc is None: + return None + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": doc.summary, + "command": " ".join(doc.path), + "description": doc.description, + "usage": _build_usage(doc), + "options": [ + { + "name": option.name, + "description": option.description, + "required": option.required, + "repeated": option.repeated, + "default": option.default, + } + for option in doc.options + ], + "examples": _build_examples(doc), + "next_commands": list(doc.next_commands), + "output": doc.output, + } + + +def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]: + seen: set[str] = set() + commands: list[dict[str, str]] = [] + for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path): + if _is_hidden_path(doc.path): + continue + if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix): + continue + subcommand = doc.path[len(path_prefix)] + if subcommand in seen: + continue + 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)) + 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], + } + ) + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "summary": summary or get_group_summary(path_prefix), + "commands": commands, + } + + +def _build_usage(doc: CommandDoc) -> str: + parts = ["tjwater", *doc.path] + for option in doc.options: + placeholder = option.name.upper().replace("-", "_") + if option.required: + parts.extend([f"--{option.name}", f"<{placeholder}>"]) + else: + parts.append(f"[--{option.name} <{placeholder}>]") + return " ".join(parts) diff --git a/agent_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md similarity index 100% rename from agent_cli_endpoint_scope.md rename to cli/tjwater_cli_endpoint_scope.md