整理 tjwater-cli 代码和文档

This commit is contained in:
2026-06-02 11:11:56 +08:00
parent 60db2a7193
commit f274cf5122
18 changed files with 3502 additions and 0 deletions
+68
View File
@@ -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": "..."
}
```
+14
View File
@@ -0,0 +1,14 @@
{
"include": [
"tjwater_agent_cli",
"tests"
],
"executionEnvironments": [
{
"root": ".",
"extraPaths": [
"."
]
}
]
}
+3
View File
@@ -0,0 +1,3 @@
click>=8.1,<9
requests>=2.31,<3
typer>=0.12,<1
+6
View File
@@ -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))
+306
View File
@@ -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 <menu> 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 <START_TIME> --duration <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 <START_TIME> --duration <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 <START_TIME> --duration <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/",
]
Executable
+17
View File
@@ -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 "$@"
+3
View File
@@ -0,0 +1,3 @@
from .main import app, main
__all__ = ["app", "main"]
+5
View File
@@ -0,0 +1,5 @@
from .main import console_entry
if __name__ == "__main__":
console_entry()
+83
View File
@@ -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"}
+531
View File
@@ -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,
)
+573
View File
@@ -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,
)
+224
View File
@@ -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,
)
+54
View File
@@ -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,
)
+647
View File
@@ -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 <command> --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
+403
View File
@@ -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 <menu> 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)
+115
View File
@@ -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 <command-path>",
"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())
+450
View File
@@ -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、infodevice-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)
+515
View File
@@ -0,0 +1,515 @@
# Agent CLI 接口范围确认
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
## 结论
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
```text
tjwater project
tjwater network
tjwater component
tjwater simulation
tjwater analysis
tjwater data
tjwater help
```
首批默认不暴露:
- 会修改 network 的接口:`add*``set*``delete*``generate*`
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
- 用户管理接口:创建、更新、删除、激活、停用
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
## 设计原则
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
- 首批只暴露 `schema``list``get``exists`、只读计算和分析类能力。
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。
- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`
- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
## 分级约束
| 顶层命令 | 二级范围 | 说明 |
|---|---|---|
| `project` | `list``info``db-health``export-inp``data` | 项目发现和只读项目数据 |
| `network` | `get-node-properties``get-link-properties` | 管网节点/管线属性查询,只读 |
| `component` | `option` | EPANET 选项设置,只读 |
| `simulation` | `run` | 模拟运行 |
| `analysis` | `burst``valve``flushing``age``contaminant``sensor-placement``leakage``burst-detection``burst-location``risk` | 任务级分析 |
| `data` | `timeseries``scada``scheme``extension``misc` | 数据查询 |
| `help` | `--json``COMMAND --json` | Agent 能力发现和命令说明 |
命令深度建议:
- 常规命令不超过 3 层:`tjwater component option get`
- 时序数据允许 4 层:`tjwater data timeseries realtime links`
- `risk` 归入 `analysis risk`
- `scada``scheme``extension` 归入 `data`
## 全局上下文与通用参数
首批 CLI 建议统一支持以下全局参数:
```text
--server URL
--auth-context PATH
--scheme SCHEME
--timeout SEC
--request-id ID
```
参数含义:
| 参数 | 含义 | 作用域 | 说明 |
|---|---|---|---|
| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 |
| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 |
| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 |
| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 |
| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 |
约束:
- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。
- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。
- `--server``--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。
- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。
参数表达建议:
- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。
- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。
- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE``NODE:VALUE``VALVE:OPENING` 这类 shell 内联 DSL。
- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help --json` 给出 schema。
## 首批 CLI 范围
### Project
来源:
```text
app/api/v1/endpoints/auth.py
app/api/v1/endpoints/meta.py
app/api/v1/endpoints/project.py
app/api/v1/endpoints/project_data.py
TJWaterFrontend_Refine/src/lib/requestHeaders.ts
TJWaterFrontend_Refine/src/lib/api.ts
TJWaterFrontend_Refine/src/lib/apiFetch.ts
```
认证模式:
- **Non-interactive / Agent**
- 面向 agent、脚本、多用户多 agent 并发调用。
- 必须显式传入认证上下文。
- 不得隐式回退到本机默认状态。
Agent 调用认证上下文:
- 当前前端调用链会自动附加以下请求头:
- `Authorization: Bearer <access_token>`
- `X-Project-Id: <projectId>`
- `X-User-Id: <userId>`
- 其中 `Authorization` 来自访问令牌,`X-Project-Id` 来自当前项目上下文,`X-User-Id` 来自当前登录用户。
- 因此前端触发的 agent 调用,应默认支持直接消费这三个字段;不再设计额外的本地 `login` 流程。
- CLI 侧建议提供两类显式注入方式:
- `--auth-context PATH`
- 环境变量 / 调用方 header 映射
认证解析优先级建议固定为:
1. 命令行显式参数(如 `--auth-context`
2. 调用方显式注入的环境变量 / header 映射
约束:
- Agent 模式下,若未显式提供认证上下文,应返回明确错误,而不是尝试复用默认登录态。
- `X-Project-Id` 是当前 project scope 的默认来源;CLI 命令默认直接使用该上下文,不要求重复传参。
- `X-User-Id` 主要用于审计、结果归属和多用户隔离,不应用来替代 access token 做认证。
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater project list` | `GET /meta/projects` | 项目列表 |
| `tjwater project info` | `GET /meta/project` | 当前 project 信息 |
| `tjwater project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 |
| `tjwater project export-inp --output PATH` | `GET /exportinp/``GET /dumpinp/``GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 |
| `tjwater project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info``GET /scheme-list``GET /burst-locate-result*` | 当前 project 的业务数据 |
暂不暴露:
```text
POST /auth/register
POST /auth/login
POST /auth/login/simple
GET /auth/me
POST /auth/refresh
GET /listprojects/
GET /project_info/
GET /haveproject/
GET /isprojectopen/
GET /isprojectlocked/
GET /isprojectlockedbyme/
POST /createproject/
POST /deleteproject/
POST /openproject/
POST /closeproject/
POST /copyproject/
POST /importinp/
POST /readinp/
POST /lockproject/
POST /unlockproject/
POST /uploadinp/
GET /convertv3tov2/
```
### Network
来源:
```text
app/api/v1/endpoints/network/*.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 |
| `tjwater network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 |
暂不暴露:
```text
add*
set*
delete*
generate*
POST /generatedistrictmeteringarea/
POST /generatesubdistrictmeteringarea/
POST /generateservicearea/
POST /generatevirtualdistrict/
```
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
### Component
来源:
```text
app/api/v1/endpoints/components/*.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema |
| `tjwater component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 |
| `tjwater component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema |
| `tjwater component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 |
| `tjwater component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema |
| `tjwater component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 |
| `tjwater component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema |
| `tjwater component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 |
暂不暴露:
```text
POST /addcurve/
POST /setcurveproperties/
POST /deletecurve/
POST /addpattern/
POST /setpatternproperties/
POST /deletepattern/
POST /settimeproperties/
POST /setenergyproperties/
GET /setpumpenergyproperties//
POST /setoptionproperties/
POST /setcontrolproperties/
POST /setruleproperties/
POST /setqualityproperties/
POST /setemitterproperties/
POST /setsource/
POST /addsource/
POST /deletesource/
POST /setreaction/
POST /setpipereaction/
POST /settankreaction/
POST /setmixing/
POST /addmixing/
POST /deletemixing/
POST /setvertexproperties/
POST /addvertex/
POST /deletevertex/
POST /setlabelproperties/
POST /addlabel/
POST /deletelabel/
POST /setbackdropproperties/
```
备注:
- `options` 当前实际只读接口分为 4 组:`time``energy``pump-energy``network`
- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`
- 后端现有路径 `GET /getpumpenergyproperties//``GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。
### Simulation / Analysis / Risk
来源:
```text
app/api/v1/endpoints/simulation.py
app/api/v1/endpoints/leakage.py
app/api/v1/endpoints/burst_detection.py
app/api/v1/endpoints/burst_location.py
app/api/v1/endpoints/risk.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater data timeseries realtime *` 查询 |
| `tjwater analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` |
| `tjwater analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/``GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 |
| `tjwater analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` |
| `tjwater analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 |
| `tjwater analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 |
| `tjwater analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
| `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/``GET /leakage/schemes/{scheme_name}` | 漏损方案查询 |
| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
| `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/``GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 |
| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
| `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/``GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 |
| `tjwater analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
| `tjwater analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
| `tjwater analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/``GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
暂缓或暂不暴露:
```text
POST /network_project/
GET /runproject/
POST /network_update/
POST /project_management/
POST /sensorplacementscheme/create
POST /pump_failure/
POST /pressure_regulation/
POST /scheduling_analysis/
POST /daily_scheduling_analysis/
```
执行模型:
- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。
- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。
- 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。
- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。
- 模拟结果读取统一走 `tjwater data timeseries realtime *`,而不是再单独设计 `simulation output`
- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。
### Data
来源:
```text
app/api/v1/endpoints/timeseries/*.py
app/api/v1/endpoints/scada.py
app/api/v1/endpoints/schemes.py
app/api/v1/endpoints/extension.py
app/api/v1/endpoints/misc.py
app/api/v1/endpoints/project_data.py
```
| 命令 | 覆盖接口 | 说明 |
|---|---|---|
| `tjwater data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 |
| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 |
| `tjwater data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 |
| `tjwater data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 |
| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links``GET /scheme/links/{link_id}/field` | 方案管道数据 |
| `tjwater data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 |
| `tjwater data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range``GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
| `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` |
| `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py``GET` 查询接口 | `SCADA` 元数据 |
| `tjwater data scheme schema\|get\|list` | `schemes.py``GET` 接口 | 当前 project 方案查询 |
| `tjwater data extension keys\|get\|list` | `extension.py``GET` 查询接口 | 当前 project 扩展数据查询 |
| `tjwater data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 |
| `tjwater data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 |
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links``nodes``simulation-by-id-time``simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
- `realtime``scheme``composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
暂不暴露:
```text
POST /realtime/*/batch
DELETE /realtime/*
PATCH /realtime/*
POST /realtime/simulation/store
POST /scheme/*/batch
PATCH /scheme/*
DELETE /scheme/*
POST /scheme/simulation/store
POST /scada/batch
PATCH /scada/{device_id}/field
DELETE /scada/by-id-time-range
POST /composite/clean-scada
POST /setscadadevice/
POST /addscadadevice/
POST /deletescadadevice/
POST /cleanscadadevice/
POST /setscadadevicedata/
POST /addscadadevicedata/
POST /deletescadadevicedata/
POST /cleanscadadevicedata/
POST /setscadaelement/
POST /addscadaelement/
POST /deletescadaelement/
POST /cleanscadaelement/
POST /setextensiondata/
POST /test_dict/
GET /getjson/
```
### 不纳入首批 CLI 的运维接口
来源:
```text
app/api/v1/endpoints/snapshots.py
app/api/v1/endpoints/cache.py
app/api/v1/endpoints/audit.py
app/api/v1/endpoints/users.py
app/api/v1/endpoints/user_management.py
```
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计、用户管理或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
暂不暴露:
```text
GET /getcurrentoperationid/
GET /getsnapshots/
GET /havesnapshot/
GET /havesnapshotforoperation/
GET /havesnapshotforcurrentoperation/
GET /getrestoreoperation/
POST /undo/
POST /redo/
POST /takesnapshot*/
POST /picksnapshot/
POST /pickoperation/
GET /syncwithserver/
POST /batch/
POST /compressedbatch/
POST /setrestoreoperation/
GET /queryredis/
POST /clearrediskey/
POST /clearrediskeys/
POST /clearallredis/
GET /audit/logs
GET /audit/logs/my
GET /audit/logs/count
GET /getuserschema/
GET /getuser/
GET /getallusers/
PUT /users/{user_id}
DELETE /users/{user_id}
POST /users/{user_id}/activate
POST /users/{user_id}/deactivate
```
## Help
`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
| 命令 | 说明 |
|---|---|
| `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
| `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 |
输出补充约束:
- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。
- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`,例如 `project export-inp --output PATH`
- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。
## 输出规范
进程级契约:
- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。
- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。
- 退出码必须稳定,不能简单透传底层 HTTP status。
建议退出码:
| 退出码 | 含义 |
|---|---|
| `0` | 成功 |
| `2` | CLI 参数错误 / 用法错误 |
| `3` | 认证失败 |
| `4` | 权限不足 |
| `5` | 资源不存在 |
| `6` | 冲突、前置条件不满足或非法状态 |
| `7` | 服务端错误 |
成功:
```json
{
"ok": true,
"schema_version": "tjwater-cli/v1",
"summary": "读取成功",
"data": {},
"metadata": {},
"next_commands": []
}
```
失败:
```json
{
"ok": false,
"schema_version": "tjwater-cli/v1",
"summary": "认证失败",
"error": {
"code": "UNAUTHENTICATED",
"message": "missing access token for agent context",
"retryable": false
},
"data": null,
"metadata": {},
"next_commands": [
"tjwater <command> --auth-context /path/to/auth-context.json"
]
}
```
补充约束:
- `metadata` 至少建议包含:`request_id``server``duration_ms``generated_at`
- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。
- 所有 `help --json` 输出也应带 `schema_version`,便于 agent 做能力协商。
## 后续开放条件
如后续要开放写操作,需要单独设计:
- 权限校验
- dry-run / preview
- 显式确认机制
- 审计日志
- 变更快照
- 回滚策略
- Agent 可读的错误恢复建议