整理 tjwater-cli 代码和文档
This commit is contained in:
@@ -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": "..."
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include": [
|
||||
"tjwater_agent_cli",
|
||||
"tests"
|
||||
],
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".",
|
||||
"extraPaths": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
click>=8.1,<9
|
||||
requests>=2.31,<3
|
||||
typer>=0.12,<1
|
||||
@@ -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))
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import app, main
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .main import console_entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console_entry()
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -0,0 +1,450 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION
|
||||
|
||||
GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||
("project",): "项目与项目级元数据相关命令。",
|
||||
("network",): "管网节点、管线等基础属性查询命令。",
|
||||
("component",): "组件选项与配置读取命令。",
|
||||
("component", "option"): "组件选项查询命令。",
|
||||
("simulation",): "模拟运行与调度相关命令。",
|
||||
("analysis",): "分析计算与诊断相关命令。",
|
||||
("analysis", "leakage"): "漏损分析相关命令。",
|
||||
("analysis", "leakage", "schemes"): "漏损方案查询命令。",
|
||||
("analysis", "burst-detection"): "爆管检测相关命令。",
|
||||
("analysis", "burst-detection", "schemes"): "爆管检测方案查询命令。",
|
||||
("analysis", "burst-location"): "爆管定位相关命令。",
|
||||
("analysis", "burst-location", "schemes"): "爆管定位方案查询命令。",
|
||||
("analysis", "risk"): "风险分析相关命令。",
|
||||
("analysis", "sensor-placement"): "传感器选址相关命令。",
|
||||
("data",): "时序、SCADA、方案和扩展数据查询命令。",
|
||||
("data", "timeseries"): "时序数据查询命令。",
|
||||
("data", "timeseries", "realtime"): "实时模拟时序查询命令。",
|
||||
("data", "timeseries", "scheme"): "方案时序查询命令。",
|
||||
("data", "timeseries", "scada"): "SCADA 时序查询命令。",
|
||||
("data", "timeseries", "composite"): "复合时序查询命令。",
|
||||
("data", "scada"): "SCADA 元数据查询命令。",
|
||||
("data", "scheme"): "方案数据查询命令。",
|
||||
("data", "extension"): "扩展数据查询命令。",
|
||||
("data", "misc"): "其他结果数据查询命令。",
|
||||
}
|
||||
|
||||
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
("analysis", "burst-location"),
|
||||
("analysis", "risk"),
|
||||
)
|
||||
|
||||
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("project", "list"): CommandDoc(
|
||||
path=("project", "list"),
|
||||
summary="列出当前用户可访问项目",
|
||||
description="调用 /meta/projects 返回项目列表。",
|
||||
examples=("tjwater --auth-context auth.json project list",),
|
||||
next_commands=("tjwater --auth-context auth.json project info",),
|
||||
output="项目摘要列表",
|
||||
),
|
||||
("project", "info"): CommandDoc(
|
||||
path=("project", "info"),
|
||||
summary="读取当前项目元数据",
|
||||
description="调用 /meta/project 返回当前 project 详情。",
|
||||
examples=("tjwater --auth-context auth.json project info",),
|
||||
output="项目元数据",
|
||||
),
|
||||
("project", "db-health"): CommandDoc(
|
||||
path=("project", "db-health"),
|
||||
summary="检查当前项目数据库健康状态",
|
||||
description="调用 /meta/db/health 返回 PostgreSQL 与 Timescale 健康状态。",
|
||||
),
|
||||
("project", "export-inp"): CommandDoc(
|
||||
path=("project", "export-inp"),
|
||||
summary="导出当前项目 INP 到本地文件",
|
||||
description="先调用 /dumpinp/ 在服务端生成 INP,再通过 /downloadinp/ 下载到本地。",
|
||||
options=(
|
||||
CommandOptionDoc("output", "本地输出路径", required=True),
|
||||
),
|
||||
output="本地文件路径和下载摘要",
|
||||
),
|
||||
("project", "data"): CommandDoc(
|
||||
path=("project", "data"),
|
||||
summary="读取当前项目业务数据",
|
||||
description="kind 支持 scada-info、scheme-list、burst-locate-result。",
|
||||
options=(CommandOptionDoc("kind", "数据类型", required=True),),
|
||||
),
|
||||
("network", "get-node-properties"): CommandDoc(
|
||||
path=("network", "get-node-properties"),
|
||||
summary="读取节点属性",
|
||||
description="调用 /getnodeproperties/。",
|
||||
options=(CommandOptionDoc("node", "节点 ID", required=True),),
|
||||
),
|
||||
("network", "get-link-properties"): CommandDoc(
|
||||
path=("network", "get-link-properties"),
|
||||
summary="读取管线属性",
|
||||
description="调用 /getlinkproperties/。",
|
||||
options=(CommandOptionDoc("link", "管线 ID", required=True),),
|
||||
),
|
||||
("component", "option", "schema"): CommandDoc(
|
||||
path=("component", "option", "schema"),
|
||||
summary="读取选项 schema",
|
||||
description="kind 支持 time、energy、pump-energy、network。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "选项类型", required=True),
|
||||
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||
),
|
||||
),
|
||||
("component", "option", "get"): CommandDoc(
|
||||
path=("component", "option", "get"),
|
||||
summary="读取选项属性",
|
||||
description="kind 支持 time、energy、pump-energy、network。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "选项类型", required=True),
|
||||
CommandOptionDoc("pump", "pump-energy 时需要的泵 ID"),
|
||||
),
|
||||
),
|
||||
("simulation", "run"): CommandDoc(
|
||||
path=("simulation", "run"),
|
||||
summary="触发指定绝对时间的模拟运行",
|
||||
description="把 RFC3339 start-time 拆成 simulation_date 与 start_time 后调用 /runsimulationmanuallybydate/;接口本身只负责触发运行,结果需后续通过 data timeseries 在对应时间段查询。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续分钟数", required=True),
|
||||
),
|
||||
next_commands=(
|
||||
"tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||
"tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||
),
|
||||
output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询",
|
||||
),
|
||||
("analysis", "burst"): CommandDoc(
|
||||
path=("analysis", "burst"),
|
||||
summary="执行爆管分析",
|
||||
description="读取 burst-file 并转换为 burst_ID[] / burst_size[];接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01",
|
||||
),
|
||||
next_commands=(
|
||||
"tjwater --auth-context auth.json data scheme get --name burst_case_01",
|
||||
"tjwater --auth-context auth.json data scheme list",
|
||||
),
|
||||
output="分析执行结果;方案详情需通过 data scheme 命令单独查询",
|
||||
),
|
||||
("analysis", "valve"): CommandDoc(
|
||||
path=("analysis", "valve"),
|
||||
summary="执行阀门关闭或隔离分析",
|
||||
description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。",
|
||||
examples=(
|
||||
"tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900",
|
||||
),
|
||||
),
|
||||
("analysis", "flushing"): CommandDoc(
|
||||
path=("analysis", "flushing"),
|
||||
summary="执行冲洗分析",
|
||||
description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。",
|
||||
),
|
||||
("analysis", "age"): CommandDoc(
|
||||
path=("analysis", "age"),
|
||||
summary="执行水龄分析",
|
||||
description="调用 /age_analysis/。",
|
||||
),
|
||||
("analysis", "contaminant"): CommandDoc(
|
||||
path=("analysis", "contaminant"),
|
||||
summary="执行污染物模拟",
|
||||
description="调用 /contaminant_simulation/。",
|
||||
),
|
||||
("analysis", "sensor-placement", "kmeans"): CommandDoc(
|
||||
path=("analysis", "sensor-placement", "kmeans"),
|
||||
summary="执行 KMeans 传感器选址",
|
||||
description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。",
|
||||
),
|
||||
("analysis", "leakage", "identify"): CommandDoc(
|
||||
path=("analysis", "leakage", "identify"),
|
||||
summary="执行漏损识别",
|
||||
description="把 CLI 时间映射到 scada_start / scada_end。",
|
||||
),
|
||||
("analysis", "leakage", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "list"),
|
||||
summary="列出漏损方案",
|
||||
description="调用 /leakage/schemes/。",
|
||||
),
|
||||
("analysis", "leakage", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "get"),
|
||||
summary="读取漏损方案详情",
|
||||
description="调用 /leakage/schemes/{scheme_name}。",
|
||||
),
|
||||
("analysis", "burst-detection", "detect"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "detect"),
|
||||
summary="执行爆管检测",
|
||||
description="调用 /burst-detection/detect/。",
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "list"),
|
||||
summary="列出爆管检测方案",
|
||||
description="调用 /burst-detection/schemes/。",
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "get"),
|
||||
summary="读取爆管检测方案详情",
|
||||
description="调用 /burst-detection/schemes/{scheme_name}。",
|
||||
),
|
||||
("analysis", "burst-location", "locate"): CommandDoc(
|
||||
path=("analysis", "burst-location", "locate"),
|
||||
summary="执行爆管定位",
|
||||
description="调用 /burst-location/locate/;需要 burst-leakage。",
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "list"),
|
||||
summary="列出爆管定位方案",
|
||||
description="调用 /burst-location/schemes/。",
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "get"),
|
||||
summary="读取爆管定位方案详情",
|
||||
description="调用 /burst-location/schemes/{scheme_name}。",
|
||||
),
|
||||
("analysis", "risk", "pipe-now"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-now"),
|
||||
summary="读取单条管道当前风险",
|
||||
description="调用 /getpiperiskprobabilitynow/。",
|
||||
),
|
||||
("analysis", "risk", "pipe-history"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-history"),
|
||||
summary="读取单条管道历史风险",
|
||||
description="调用 /getpiperiskprobability/。",
|
||||
),
|
||||
("analysis", "risk", "network"): CommandDoc(
|
||||
path=("analysis", "risk", "network"),
|
||||
summary="读取全网风险",
|
||||
description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。",
|
||||
),
|
||||
("data", "timeseries", "realtime", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "links"),
|
||||
summary="查询实时管道时序",
|
||||
description="调用 /realtime/links。",
|
||||
),
|
||||
("data", "timeseries", "realtime", "nodes"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "nodes"),
|
||||
summary="查询实时节点时序",
|
||||
description="调用 /realtime/nodes。",
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
|
||||
summary="按元素和时间查询实时模拟结果",
|
||||
description="调用 /realtime/query/by-id-time。",
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||
summary="按时间和属性查询实时模拟结果",
|
||||
description="调用 /realtime/query/by-time-property。",
|
||||
),
|
||||
("data", "timeseries", "scheme", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "links"),
|
||||
summary="查询方案管道时序",
|
||||
description="调用 /scheme/links。",
|
||||
),
|
||||
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "node-field"),
|
||||
summary="查询方案节点字段时序",
|
||||
description="调用 /scheme/nodes/{node_id}/field。",
|
||||
),
|
||||
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "simulation"),
|
||||
summary="查询方案模拟数据",
|
||||
description="支持 by-id-time 与 by-scheme-time-property 两种查询。",
|
||||
),
|
||||
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||
path=("data", "timeseries", "scada", "query"),
|
||||
summary="查询 SCADA 时序",
|
||||
description="device-id 会被转换成后端逗号分隔参数。",
|
||||
),
|
||||
("data", "timeseries", "composite"): CommandDoc(
|
||||
path=("data", "timeseries", "composite"),
|
||||
summary="执行复合时序查询",
|
||||
description="kind 支持 scada-simulation、element-simulation、element-scada。",
|
||||
),
|
||||
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
|
||||
path=("data", "timeseries", "composite", "pipeline-health"),
|
||||
summary="查询管道健康预测",
|
||||
description="调用 /composite/pipeline-health-prediction。",
|
||||
),
|
||||
("data", "scada", "schema"): CommandDoc(
|
||||
path=("data", "scada", "schema"),
|
||||
summary="读取 SCADA schema",
|
||||
description="kind 支持 device、device-data、element、info。",
|
||||
),
|
||||
("data", "scada", "get"): CommandDoc(
|
||||
path=("data", "scada", "get"),
|
||||
summary="读取单条 SCADA 元数据",
|
||||
description="kind 支持 device、device-data、element、info。",
|
||||
),
|
||||
("data", "scada", "list"): CommandDoc(
|
||||
path=("data", "scada", "list"),
|
||||
summary="列出 SCADA 元数据",
|
||||
description="kind 支持 device、element、info;device-data 当前后端无 list 接口。",
|
||||
),
|
||||
("data", "scheme", "schema"): CommandDoc(
|
||||
path=("data", "scheme", "schema"),
|
||||
summary="读取方案 schema",
|
||||
description="调用 /getschemeschema/。",
|
||||
),
|
||||
("data", "scheme", "get"): CommandDoc(
|
||||
path=("data", "scheme", "get"),
|
||||
summary="读取单条方案",
|
||||
description="调用 /getscheme/。",
|
||||
),
|
||||
("data", "scheme", "list"): CommandDoc(
|
||||
path=("data", "scheme", "list"),
|
||||
summary="列出方案",
|
||||
description="调用 /getallschemes/。",
|
||||
),
|
||||
("data", "extension", "keys"): CommandDoc(
|
||||
path=("data", "extension", "keys"),
|
||||
summary="列出扩展数据键",
|
||||
description="调用 /getallextensiondatakeys/。",
|
||||
),
|
||||
("data", "extension", "get"): CommandDoc(
|
||||
path=("data", "extension", "get"),
|
||||
summary="读取扩展数据",
|
||||
description="调用 /getextensiondata/。",
|
||||
),
|
||||
("data", "extension", "list"): CommandDoc(
|
||||
path=("data", "extension", "list"),
|
||||
summary="列出扩展数据",
|
||||
description="调用 /getallextensiondata/。",
|
||||
),
|
||||
("data", "misc", "sensor-placements"): CommandDoc(
|
||||
path=("data", "misc", "sensor-placements"),
|
||||
summary="列出传感器布置结果",
|
||||
description="调用 /getallsensorplacements/。",
|
||||
),
|
||||
("data", "misc", "burst-location-results"): CommandDoc(
|
||||
path=("data", "misc", "burst-location-results"),
|
||||
summary="列出爆管定位结果",
|
||||
description="调用 /getallburstlocateresults/。",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_examples(doc: CommandDoc) -> list[str]:
|
||||
return list(doc.examples) if doc.examples else [_build_usage(doc)]
|
||||
|
||||
|
||||
def _is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||
return any(path[: len(prefix)] == prefix for prefix in HIDDEN_PATH_PREFIXES)
|
||||
|
||||
|
||||
def is_hidden_path(path: tuple[str, ...]) -> bool:
|
||||
return _is_hidden_path(path)
|
||||
|
||||
|
||||
def has_subcommands(path_prefix: tuple[str, ...]) -> bool:
|
||||
return any(
|
||||
not _is_hidden_path(doc.path)
|
||||
and doc.path[: len(path_prefix)] == path_prefix
|
||||
and len(doc.path) > len(path_prefix)
|
||||
for doc in COMMAND_DOCS.values()
|
||||
)
|
||||
|
||||
|
||||
def get_group_summary(path_prefix: tuple[str, ...]) -> str:
|
||||
return GROUP_SUMMARIES.get(path_prefix, f"{' '.join(path_prefix)} 可用子命令")
|
||||
|
||||
|
||||
def list_capabilities() -> dict[str, object]:
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
commands: list[dict[str, str]] = []
|
||||
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||
if _is_hidden_path(doc.path):
|
||||
continue
|
||||
prefix = doc.path[:1]
|
||||
if prefix in seen:
|
||||
continue
|
||||
seen.add(prefix)
|
||||
commands.append(
|
||||
{
|
||||
"command": " ".join(prefix),
|
||||
"summary": get_group_summary(prefix),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": "可用一级菜单",
|
||||
"menu_level": 1,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
def get_command_doc(path: tuple[str, ...]) -> dict[str, object] | None:
|
||||
if _is_hidden_path(path):
|
||||
return None
|
||||
doc = COMMAND_DOCS.get(path)
|
||||
if doc is None:
|
||||
return None
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": doc.summary,
|
||||
"command": " ".join(doc.path),
|
||||
"description": doc.description,
|
||||
"usage": _build_usage(doc),
|
||||
"options": [
|
||||
{
|
||||
"name": option.name,
|
||||
"description": option.description,
|
||||
"required": option.required,
|
||||
"repeated": option.repeated,
|
||||
"default": option.default,
|
||||
}
|
||||
for option in doc.options
|
||||
],
|
||||
"examples": _build_examples(doc),
|
||||
"next_commands": list(doc.next_commands),
|
||||
"output": doc.output,
|
||||
}
|
||||
|
||||
|
||||
def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) -> dict[str, object]:
|
||||
seen: set[str] = set()
|
||||
commands: list[dict[str, str]] = []
|
||||
for doc in sorted(COMMAND_DOCS.values(), key=lambda item: item.path):
|
||||
if _is_hidden_path(doc.path):
|
||||
continue
|
||||
if doc.path[: len(path_prefix)] != path_prefix or len(doc.path) <= len(path_prefix):
|
||||
continue
|
||||
subcommand = doc.path[len(path_prefix)]
|
||||
if subcommand in seen:
|
||||
continue
|
||||
seen.add(subcommand)
|
||||
current_path = (*path_prefix, subcommand)
|
||||
is_group = has_subcommands(current_path)
|
||||
usage = f"tjwater {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
|
||||
commands.append(
|
||||
{
|
||||
"command": " ".join(current_path),
|
||||
"summary": get_group_summary(current_path) if is_group else doc.summary,
|
||||
"usage": usage,
|
||||
"example": f"tjwater {' '.join(current_path)} help" if is_group else _build_examples(doc)[0],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"summary": summary or get_group_summary(path_prefix),
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
def _build_usage(doc: CommandDoc) -> str:
|
||||
parts = ["tjwater", *doc.path]
|
||||
for option in doc.options:
|
||||
placeholder = option.name.upper().replace("-", "_")
|
||||
if option.required:
|
||||
parts.extend([f"--{option.name}", f"<{placeholder}>"])
|
||||
else:
|
||||
parts.append(f"[--{option.name} <{placeholder}>]")
|
||||
return " ".join(parts)
|
||||
Reference in New Issue
Block a user