合并 agent-mvp 到 master #1
@@ -4,7 +4,7 @@
|
||||
|
||||
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
|
||||
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
@@ -29,7 +29,7 @@ The project uses `pytest`. Name test files `test_*.py` and test functions `test_
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding` or `fix(auth): validate project context`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
|
||||
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TJWaterServerBinary 内部后端
|
||||
|
||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。
|
||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -23,7 +23,6 @@ app/infra/ 数据库、缓存、EPANET 和外部集成
|
||||
app/services/ 业务服务编排
|
||||
app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析
|
||||
app/native/ 本地管网数据读写与转换
|
||||
cli/ tjwater-cli 命令行工具
|
||||
tests/ 后端测试
|
||||
resources/ SQL、模板和示例资源
|
||||
infra/docker/ Docker Compose 编排
|
||||
@@ -58,16 +57,6 @@ docker compose -f infra/docker/docker-compose.yml config
|
||||
- `docker build`:构建后端镜像。
|
||||
- `docker compose config`:检查 compose 配置和变量展开。
|
||||
|
||||
## CLI
|
||||
|
||||
CLI 位于 `cli/tjwater_cli`,说明见:
|
||||
|
||||
```text
|
||||
cli/README.md
|
||||
```
|
||||
|
||||
修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。
|
||||
|
||||
## 开发规范
|
||||
|
||||
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
@@ -1,79 +0,0 @@
|
||||
# TJWater CLI
|
||||
|
||||
独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。
|
||||
|
||||
## 构建可执行产物
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-build.txt
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
构建完成后,直接使用编译产物:
|
||||
|
||||
```bash
|
||||
./dist/tjwater-cli/tjwater-cli help
|
||||
```
|
||||
|
||||
这个可执行文件可以直接参与管道:
|
||||
|
||||
```bash
|
||||
./dist/tjwater-cli/tjwater-cli help | jq
|
||||
```
|
||||
|
||||
当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。
|
||||
|
||||
如果需要在开发时直接走源码入口,也可以显式使用 Python:
|
||||
|
||||
```bash
|
||||
python -m tjwater_cli help
|
||||
```
|
||||
|
||||
## 部署到 agent 服务器
|
||||
|
||||
最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行:
|
||||
|
||||
```bash
|
||||
./tjwater-cli/tjwater-cli help
|
||||
```
|
||||
|
||||
如果希望打包传输:
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli
|
||||
```
|
||||
|
||||
如果希望放到 PATH 中:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli
|
||||
tjwater-cli help | jq
|
||||
```
|
||||
|
||||
## 运行与构建依赖
|
||||
|
||||
```bash
|
||||
cd TJWaterServerBinary/cli
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-build.txt
|
||||
```
|
||||
|
||||
`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。
|
||||
|
||||
## 认证上下文
|
||||
|
||||
CLI 通过 `--auth-context` 读取 JSON 文件。常用字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": "http://backend-host:8000",
|
||||
"access_token": "...",
|
||||
"project_id": "...",
|
||||
"network": "...",
|
||||
"username": "..."
|
||||
}
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -n "${PYTHON:-}" ]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec
|
||||
|
||||
BIN_PATH="$ROOT/dist/"
|
||||
if [ ! -x "$BIN_PATH" ]; then
|
||||
echo "build succeeded but executable was not created: $BIN_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$BIN_PATH" help >/dev/null
|
||||
|
||||
echo "built executable: $BIN_PATH"
|
||||
@@ -1,5 +0,0 @@
|
||||
from tjwater_cli.main import console_entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console_entry()
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"tjwater_cli",
|
||||
"tests"
|
||||
],
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".",
|
||||
"extraPaths": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pyinstaller>=6.11,<7
|
||||
@@ -1,3 +0,0 @@
|
||||
click>=8.1,<9
|
||||
requests>=2.31,<3
|
||||
typer>=0.12,<1
|
||||
@@ -1,6 +0,0 @@
|
||||
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))
|
||||
@@ -1,930 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tjwater_cli import common, core
|
||||
from tjwater_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(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setenv("TJWATER_PROJECT_ID", "p1")
|
||||
|
||||
auth = core.load_auth_context(auth_stdin=False)
|
||||
|
||||
assert auth.server == "http://server"
|
||||
assert auth.access_token == "abc"
|
||||
assert auth.project_id == "p1"
|
||||
|
||||
|
||||
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_EXTRA_HEADERS", raising=False)
|
||||
|
||||
runtime = core.build_runtime_context(
|
||||
server=None,
|
||||
scheme=None,
|
||||
timeout=core.DEFAULT_TIMEOUT,
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
assert runtime.server == core.DEFAULT_SERVER
|
||||
|
||||
|
||||
def test_auth_stdin_can_be_reused_with_runtime_context_cache(monkeypatch):
|
||||
observed_runtime_ids: list[int] = []
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
observed_runtime_ids.append(id(ctx))
|
||||
assert ctx.auth.access_token == "token-1"
|
||||
assert kwargs["params"] == {"junction": "11"}
|
||||
return {"id": "11"}, 5
|
||||
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--auth-stdin", "network", "get-junction-properties", "--junction", "11"],
|
||||
input=json.dumps(
|
||||
{
|
||||
"server": "http://server",
|
||||
"access_token": "token-1",
|
||||
"project_id": "project-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "11"}
|
||||
assert len(observed_runtime_ids) == 1
|
||||
|
||||
|
||||
def test_network_get_junction_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "J1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-junction-properties", "--junction", "J1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "J1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/junctions/properties",
|
||||
"params": {"junction": "J1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_pipe_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "P1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pipe-properties", "--pipe", "P1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "P1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pipes/properties",
|
||||
"params": {"pipe": "P1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pipes_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "P1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pipes-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "P1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pipes",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "R1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-reservoir-properties", "--reservoir", "R1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "R1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/reservoirs/properties",
|
||||
"params": {"reservoir": "R1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_reservoir_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "R1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-reservoirs-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "R1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/reservoirs",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "T1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-tank-properties", "--tank", "T1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "T1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/tanks/properties",
|
||||
"params": {"tank": "T1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_tank_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "T1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-tanks-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "T1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/tanks",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "PU1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-pump-properties", "--pump", "PU1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "PU1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pumps/properties",
|
||||
"params": {"pump": "PU1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_pump_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "PU1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-pumps-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "PU1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/pumps",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return {"id": "V1"}, 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-valve-properties", "--valve", "V1"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == {"id": "V1"}
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/valves/properties",
|
||||
"params": {"valve": "V1"},
|
||||
}
|
||||
|
||||
|
||||
def test_network_get_all_valve_properties_uses_network_context(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request_json(ctx, **kwargs):
|
||||
captured["access_token"] = ctx.auth.access_token
|
||||
captured["path"] = kwargs["path"]
|
||||
captured["params"] = kwargs["params"]
|
||||
return [{"id": "V1"}], 5
|
||||
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
monkeypatch.setattr(common, "request_json", fake_request_json)
|
||||
|
||||
result = runner.invoke(app, ["network", "get-all-valves-properties"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"] == [{"id": "V1"}]
|
||||
assert captured == {
|
||||
"access_token": "abc",
|
||||
"path": "/valves",
|
||||
"params": {},
|
||||
}
|
||||
|
||||
|
||||
def test_help_outputs_json_lists_commands():
|
||||
result = runner.invoke(app, ["help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["schema_version"] == "tjwater-cli/v1"
|
||||
assert any(command["command"] == "analysis" for command in payload["commands"])
|
||||
assert all(command["command"] != "project" for command in payload["commands"])
|
||||
assert payload["menu_level"] == 1
|
||||
assert all(command["command"] != "project list" for command in payload["commands"])
|
||||
|
||||
|
||||
def test_help_option_json_is_removed():
|
||||
result = runner.invoke(app, ["help", "--json"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "No such option: --json" in result.output
|
||||
|
||||
|
||||
def test_simulation_help_lists_subcommands():
|
||||
result = runner.invoke(app, ["simulation", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["summary"] == "模拟运行与调度相关命令。"
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行"
|
||||
assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||
assert "tjwater-cli" in commands["simulation run"]["example"]
|
||||
assert "simulation run" in commands["simulation run"]["example"]
|
||||
|
||||
|
||||
def test_nested_group_help_lists_examples():
|
||||
result = runner.invoke(app, ["analysis", "leakage", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["summary"] == "漏损分析相关命令。"
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
assert commands["analysis leakage identify"]["summary"] == "执行漏损识别"
|
||||
assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"
|
||||
|
||||
|
||||
def test_analysis_help_uses_group_summaries_for_nested_groups():
|
||||
result = runner.invoke(app, ["analysis", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
commands = {command["command"]: command for command in payload["commands"]}
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。"
|
||||
assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。"
|
||||
assert "analysis burst-location" not in commands
|
||||
assert "analysis risk" not in commands
|
||||
assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01"
|
||||
assert commands["analysis valve"]["example"] == "tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01"
|
||||
|
||||
|
||||
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_outputs_json():
|
||||
result = runner.invoke(app, ["help", "simulation", "run"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert payload["command"] == "simulation run"
|
||||
assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询"
|
||||
assert payload["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
|
||||
assert len(payload["examples"]) == 1
|
||||
assert "simulation run" in payload["examples"][0]
|
||||
|
||||
|
||||
def test_root_help_flag_uses_typer_style_with_examples():
|
||||
result = runner.invoke(app, ["--help"], prog_name="tjwater-cli")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage: tjwater-cli" in result.stdout
|
||||
assert "Examples:" in result.stdout
|
||||
assert "tjwater-cli help simulation run" in result.stdout
|
||||
|
||||
|
||||
def test_leaf_help_flag_includes_usage_and_example():
|
||||
result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout
|
||||
assert "Usage example:" in result.stdout
|
||||
assert "--start-time <START_TIME>" in result.stdout
|
||||
assert "--duration" in result.stdout
|
||||
assert "Examples:" in result.stdout
|
||||
assert "tjwater-cli simulation run" in result.stdout
|
||||
assert "START_TIME" in result.stdout
|
||||
assert "DURATION" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_simulation_help_clarifies_type_values():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-id-time", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "links/nodes 是子命令" in result.stdout
|
||||
assert "pipe" in result.stdout
|
||||
assert "junction" in result.stdout
|
||||
|
||||
|
||||
def test_realtime_property_help_lists_supported_fields():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["data", "timeseries", "realtime", "simulation-by-time-property", "--help"],
|
||||
prog_name="tjwater-cli",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "flow" in result.stdout
|
||||
assert "pressure" in result.stdout
|
||||
assert "actual_demand" in result.stdout
|
||||
assert "velocity" in result.stdout
|
||||
|
||||
|
||||
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
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,
|
||||
[
|
||||
"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-cli data scheme get --name burst_case_01" in result.stdout
|
||||
assert "tjwater-cli data scheme list" in result.stdout
|
||||
|
||||
|
||||
def test_analysis_contaminant_sends_required_scheme_name(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"contaminant",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"900",
|
||||
"--source-node",
|
||||
"N1",
|
||||
"--concentration",
|
||||
"10.0",
|
||||
"--scheme",
|
||||
"contam_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"source": "N1",
|
||||
"concentration": 10.0,
|
||||
"duration": 900,
|
||||
"scheme_name": "contam_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_flushing_sends_required_scheme_name(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
valve_path = tmp_path / "valve.json"
|
||||
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"flushing",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve-setting-file",
|
||||
str(valve_path),
|
||||
"--drainage-node",
|
||||
"N1",
|
||||
"--flow",
|
||||
"100.0",
|
||||
"--duration",
|
||||
"900",
|
||||
"--scheme",
|
||||
"flush_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"valves": ["V1"],
|
||||
"valves_k": [0.5],
|
||||
"drainage_node_id": "N1",
|
||||
"flush_flow": 100.0,
|
||||
"duration": 900,
|
||||
"scheme_name": "flush_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_valve_close_sends_required_scheme_name(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
captured = {}
|
||||
|
||||
def fake_request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return DummyResponse(text="success", headers={"content-type": "text/plain"})
|
||||
|
||||
monkeypatch.setattr(core.requests, "request", fake_request)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analysis",
|
||||
"valve",
|
||||
"--mode",
|
||||
"close",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve",
|
||||
"V1",
|
||||
"--duration",
|
||||
"900",
|
||||
"--scheme",
|
||||
"valve_case_01",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["params"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"valves": ["V1"],
|
||||
"duration": 900,
|
||||
"scheme_name": "valve_case_01",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_contaminant_requires_scheme(monkeypatch, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"contaminant",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"900",
|
||||
"--source-node",
|
||||
"N1",
|
||||
"--concentration",
|
||||
"10.0",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||
|
||||
|
||||
def test_analysis_flushing_requires_scheme(monkeypatch, tmp_path: Path, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
valve_path = tmp_path / "valve.json"
|
||||
valve_path.write_text('[{"valve":"V1","opening":0.5}]', encoding="utf-8")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"flushing",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve-setting-file",
|
||||
str(valve_path),
|
||||
"--drainage-node",
|
||||
"N1",
|
||||
"--flow",
|
||||
"100.0",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in stdout
|
||||
|
||||
|
||||
def test_analysis_valve_close_requires_scheme(monkeypatch, capsys):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"analysis",
|
||||
"valve",
|
||||
"--mode",
|
||||
"close",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--valve",
|
||||
"V1",
|
||||
"--duration",
|
||||
"900",
|
||||
],
|
||||
)
|
||||
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "SCHEME_REQUIRED"' in 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-cli simulation run --start-time <START_TIME> --duration <DURATION>"' in stdout
|
||||
assert '"tjwater-cli help simulation run"' in stdout
|
||||
|
||||
|
||||
def test_main_invalid_enum_value_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-id-time",
|
||||
"--id",
|
||||
"J1",
|
||||
"--type",
|
||||
"links",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"summary": "参数无效"' in stdout
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "links" in stdout
|
||||
assert "pipe" in stdout
|
||||
assert "junction" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_pipe_property_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"realtime",
|
||||
"simulation-by-time-property",
|
||||
"--type",
|
||||
"pipe",
|
||||
"--time",
|
||||
"2025-01-02T03:30:00+08:00",
|
||||
"--property",
|
||||
"pressure",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PROPERTY"' in stdout
|
||||
assert "flow" in stdout
|
||||
assert "velocity" in stdout
|
||||
|
||||
|
||||
def test_main_invalid_scada_field_is_rejected_before_request(capsys):
|
||||
exit_code = main(
|
||||
[
|
||||
"data",
|
||||
"timeseries",
|
||||
"scada",
|
||||
"query",
|
||||
"--device-id",
|
||||
"D1",
|
||||
"--start-time",
|
||||
"2025-01-02T03:00:00+08:00",
|
||||
"--end-time",
|
||||
"2025-01-02T04:00:00+08:00",
|
||||
"--field",
|
||||
"flow",
|
||||
]
|
||||
)
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_FIELD"' in stdout
|
||||
assert "monitored_value" in stdout
|
||||
assert "cleaned_value" in stdout
|
||||
|
||||
|
||||
def test_data_scada_get_rejects_removed_kind_before_request(capsys):
|
||||
exit_code = main(["data", "scada", "get", "--kind", "device", "--id", "D1"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "INVALID_PARAMETER"' in stdout
|
||||
assert "device" in stdout
|
||||
assert "info" in stdout
|
||||
|
||||
|
||||
def test_data_scada_list_help_only_shows_info_kind():
|
||||
result = runner.invoke(app, ["data", "scada", "list", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "info" in result.stdout
|
||||
assert "device" not in result.stdout
|
||||
assert "element" not in result.stdout
|
||||
|
||||
|
||||
def test_data_scada_help_no_longer_lists_schema():
|
||||
result = runner.invoke(app, ["data", "scada", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data scada get" in commands
|
||||
assert "data scada list" in commands
|
||||
assert "data scada schema" not in commands
|
||||
|
||||
|
||||
def test_data_scada_schema_command_is_removed():
|
||||
result = runner.invoke(app, ["data", "scada", "schema", "--kind", "info"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "No such command 'schema'" in result.output
|
||||
|
||||
|
||||
def test_data_help_no_longer_lists_extension_or_misc():
|
||||
result = runner.invoke(app, ["data", "help"])
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
commands = {command["command"] for command in payload["commands"]}
|
||||
assert "data timeseries" in commands
|
||||
assert "data scada" in commands
|
||||
assert "data scheme" in commands
|
||||
assert "data extension" not in commands
|
||||
assert "data misc" not in commands
|
||||
|
||||
|
||||
def test_removed_data_extension_and_misc_commands_fail():
|
||||
extension_result = runner.invoke(app, ["data", "extension", "list"])
|
||||
misc_result = runner.invoke(app, ["data", "misc", "sensor-placements"])
|
||||
|
||||
assert extension_result.exit_code == 2
|
||||
assert "No such command 'extension'" in extension_result.output
|
||||
assert misc_result.exit_code == 2
|
||||
assert "No such command 'misc'" in misc_result.output
|
||||
|
||||
|
||||
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-cli analysis" in stdout
|
||||
assert "分析计算与诊断相关命令。" in stdout
|
||||
assert '"ok": false' not in stdout
|
||||
|
||||
|
||||
def test_simulation_run_translates_rfc3339(monkeypatch):
|
||||
monkeypatch.setenv("TJWATER_SERVER", "http://server")
|
||||
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
|
||||
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,
|
||||
[
|
||||
"simulation",
|
||||
"run",
|
||||
"--start-time",
|
||||
"2025-01-02T03:04:05+08:00",
|
||||
"--duration",
|
||||
"30",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["json"] == {
|
||||
"start_time": "2025-01-02T03:04:05+08:00",
|
||||
"duration": 30,
|
||||
}
|
||||
assert "tjwater-cli data timeseries realtime links" in result.stdout
|
||||
assert "tjwater-cli data timeseries realtime nodes" in result.stdout
|
||||
|
||||
|
||||
def test_removed_project_command_returns_not_found(capsys):
|
||||
exit_code = main(["project", "list"])
|
||||
stdout = capsys.readouterr().out
|
||||
|
||||
assert exit_code == 2
|
||||
assert '"code": "COMMAND_NOT_FOUND"' in stdout or "No such command: project" in stdout
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
|
||||
datas = collect_data_files("certifi")
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["entrypoint.py"],
|
||||
pathex=["."],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="tjwater-cli",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name="tjwater-cli",
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .main import app, main
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from .main import console_entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console_entry()
|
||||
@@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from .formatters import TJWaterGroup
|
||||
|
||||
app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup)
|
||||
network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup)
|
||||
|
||||
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")
|
||||
|
||||
GROUP_HELP_APPS: list[tuple[typer.Typer, tuple[str, ...]]] = [
|
||||
(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")),
|
||||
]
|
||||
|
||||
TOP_LEVEL_COMMANDS = {"help", "network", "component", "simulation", "analysis", "data"}
|
||||
@@ -1,496 +0,0 @@
|
||||
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,
|
||||
resolve_scheme,
|
||||
)
|
||||
from .option_types import DataSource, ValveMode
|
||||
|
||||
|
||||
@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)
|
||||
parsed = parse_time_with_timezone(start_time, option_name="--start-time")
|
||||
end_time = (parsed + timedelta(minutes=duration)).isoformat()
|
||||
body = {
|
||||
"start_time": parsed.replace(microsecond=0).isoformat(),
|
||||
"duration": duration,
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="触发模拟成功",
|
||||
method="POST",
|
||||
path="/simulation-runs",
|
||||
json_body=body,
|
||||
require_auth=True,
|
||||
next_commands=[
|
||||
f"tjwater-cli data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
|
||||
f"tjwater-cli 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 = {
|
||||
"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="POST",
|
||||
path="/burst-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
next_commands=[
|
||||
f"tjwater-cli data scheme get --name {scheme_name}",
|
||||
"tjwater-cli data scheme list",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@analysis_app.command("valve")
|
||||
def analysis_valve(
|
||||
ctx: typer.Context,
|
||||
mode: Annotated[ValveMode, 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,
|
||||
scheme: Annotated[str | None, typer.Option("--scheme", help="close 模式的方案名称")] = None,
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
if mode == ValveMode.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 = {
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"valves": valve,
|
||||
"duration": duration or 900,
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="阀门关闭分析执行成功",
|
||||
method="POST",
|
||||
path="/valve-isolation-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
return
|
||||
if mode == ValveMode.ISOLATION:
|
||||
if not element:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_VALVE_ISOLATION_ARGS",
|
||||
message="isolation mode requires at least one --element",
|
||||
exit_code=2,
|
||||
)
|
||||
params = {"accident_element": element}
|
||||
if disabled_valve:
|
||||
params["disabled_valves"] = disabled_valve
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="阀门隔离分析执行成功",
|
||||
method="POST",
|
||||
path="/valve-isolation-analyses",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable valve mode: {mode}")
|
||||
|
||||
|
||||
@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 = {
|
||||
"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, required=True),
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="冲洗分析执行成功",
|
||||
method="POST",
|
||||
path="/flushing-analyses",
|
||||
params=params,
|
||||
require_auth=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="POST",
|
||||
path="/water-age-analyses",
|
||||
params={
|
||||
"start_time": parse_time_with_timezone(start_time, option_name="--start-time").isoformat(),
|
||||
"duration": duration,
|
||||
},
|
||||
require_auth=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 = {
|
||||
"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, required=True),
|
||||
}
|
||||
if pattern:
|
||||
params["pattern"] = pattern
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="污染物模拟执行成功",
|
||||
method="POST",
|
||||
path="/contaminant-simulations",
|
||||
params=params,
|
||||
require_auth=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 = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"sensor_number": count,
|
||||
"min_diameter": min_diameter,
|
||||
}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="传感器选址执行成功",
|
||||
method="POST",
|
||||
path="/pressure-sensor-placement-kmeans",
|
||||
json_body=body,
|
||||
require_auth=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 = {
|
||||
"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-identifications",
|
||||
json_body=body,
|
||||
require_auth=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="/schemes",
|
||||
params={
|
||||
"scheme_type": "dma_leak_identification",
|
||||
},
|
||||
require_auth=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"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "dma_leak_identification",
|
||||
},
|
||||
require_auth=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 = {
|
||||
"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-detections",
|
||||
json_body=body,
|
||||
require_auth=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="/schemes",
|
||||
params={
|
||||
"scheme_type": "burst_detection",
|
||||
},
|
||||
require_auth=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"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "burst_detection",
|
||||
},
|
||||
require_auth=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[DataSource, typer.Option("--data-source", help="数据来源,仅支持 monitoring|simulation")] = DataSource.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 = {
|
||||
"scheme_name": resolve_scheme(runtime, scheme, required=True),
|
||||
"data_source": data_source.value,
|
||||
"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-locations",
|
||||
json_body=body,
|
||||
require_auth=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="/schemes",
|
||||
params={
|
||||
"scheme_type": "burst_location",
|
||||
},
|
||||
require_auth=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"/schemes/{scheme_name}",
|
||||
params={
|
||||
"scheme_type": "burst_location",
|
||||
},
|
||||
require_auth=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="/pipes/risk-probability-now",
|
||||
params={"pipe_id": pipe},
|
||||
require_auth=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="/pipes/risk-probability",
|
||||
params={"pipe_id": pipe},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@analysis_risk_app.command("network")
|
||||
def analysis_risk_network(ctx: typer.Context) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
probabilities, duration_prob = request_json(
|
||||
runtime,
|
||||
method="GET",
|
||||
path="/network-pipe-risk-probability-nows",
|
||||
require_auth=True,
|
||||
)
|
||||
geometries, duration_geo = request_json(
|
||||
runtime,
|
||||
method="GET",
|
||||
path="/pipes/risk-probability-geometries",
|
||||
require_auth=True,
|
||||
)
|
||||
emit_success(
|
||||
summary="读取全网风险成功",
|
||||
data={"probabilities": probabilities, "geometries": geometries},
|
||||
ctx=runtime,
|
||||
duration_ms=duration_prob + duration_geo,
|
||||
)
|
||||
@@ -1,502 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .apps import (
|
||||
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, resolve_scheme
|
||||
from .option_types import (
|
||||
CompositeKind,
|
||||
ElementType,
|
||||
JUNCTION_TIMESERIES_FIELDS,
|
||||
SCADA_TIMESERIES_FIELDS,
|
||||
ScadaListKind,
|
||||
SimulationQuery,
|
||||
timeseries_fields_for_element_type,
|
||||
)
|
||||
|
||||
|
||||
def _scheme_type_option(scheme_type: str | None) -> str:
|
||||
return scheme_type or "simulation"
|
||||
|
||||
|
||||
def _backend_element_type(element_type: ElementType) -> str:
|
||||
return "link" if element_type == ElementType.PIPE else "node"
|
||||
|
||||
|
||||
def _validate_element_property(element_type: ElementType, property_name: str, *, option_name: str) -> str:
|
||||
valid_fields = timeseries_fields_for_element_type(element_type)
|
||||
if property_name not in valid_fields:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_PROPERTY",
|
||||
message=f"{option_name} for --type {element_type.value} must be one of: {', '.join(valid_fields)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return property_name
|
||||
|
||||
|
||||
def _validate_node_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in JUNCTION_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(JUNCTION_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
def _validate_scada_field(field_name: str, *, option_name: str) -> str:
|
||||
if field_name not in SCADA_TIMESERIES_FIELDS:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_FIELD",
|
||||
message=f"{option_name} must be one of: {', '.join(SCADA_TIMESERIES_FIELDS)}",
|
||||
exit_code=2,
|
||||
)
|
||||
return field_name
|
||||
|
||||
|
||||
@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="/timeseries/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="/timeseries/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[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时模拟数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/simulation-results",
|
||||
params={
|
||||
"id": id,
|
||||
"type": _backend_element_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[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")],
|
||||
time: Annotated[str, typer.Option("--time", help="查询时间")],
|
||||
property: Annotated[str, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")],
|
||||
) -> None:
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取实时属性聚合数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/realtime/records",
|
||||
params={
|
||||
"type": _backend_element_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="/timeseries/schemes/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="字段名,仅支持 actual_demand|total_head|pressure|quality")],
|
||||
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)
|
||||
field = _validate_node_field(field, option_name="--field")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案节点字段成功",
|
||||
method="GET",
|
||||
path=f"/timeseries/schemes/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[SimulationQuery, 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[ElementType, typer.Option("--type", help="元素类型,仅支持 pipe|junction;links/nodes 是子命令")] = ElementType.PIPE,
|
||||
property: Annotated[str | None, typer.Option("--property", help="属性名;pipe: flow|friction|headloss|quality|reaction|setting|status|velocity;junction: actual_demand|total_head|pressure|quality")] = 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": _backend_element_type(type),
|
||||
}
|
||||
if query == SimulationQuery.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="/timeseries/schemes/simulation-results",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if query == SimulationQuery.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,
|
||||
)
|
||||
property = _validate_element_property(type, property, option_name="--property")
|
||||
params["property"] = property
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取方案属性聚合数据成功",
|
||||
method="GET",
|
||||
path="/timeseries/schemes/records",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable query variant: {query}")
|
||||
|
||||
|
||||
@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="字段名,仅支持 monitored_value|cleaned_value")] = None,
|
||||
) -> None:
|
||||
path = "/timeseries/scada-readings/fields" if field else "/timeseries/scada-readings"
|
||||
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:
|
||||
field = _validate_scada_field(field, option_name="--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[CompositeKind | 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 == CompositeKind.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="/timeseries/views/scada-simulations",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == CompositeKind.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="/timeseries/views/element-simulations",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
if kind == CompositeKind.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="/timeseries/views/element-scada-readings",
|
||||
params=params,
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"unreachable composite kind: {kind}")
|
||||
|
||||
|
||||
@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="/pipeline-health-predictions",
|
||||
params={
|
||||
"query_time": parse_time_with_timezone(end_time, option_name="--end-time").isoformat(),
|
||||
},
|
||||
require_auth=True,
|
||||
require_project=True,
|
||||
)
|
||||
|
||||
|
||||
def _scada_mapping(kind: str, action: str) -> tuple[str, dict[str, str]]:
|
||||
mapping = {
|
||||
("info", "get"): ("/scada-info/detail", {"id_param": "id"}),
|
||||
("info", "list"): ("/scada-info", {}),
|
||||
}
|
||||
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("get")
|
||||
def data_scada_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
id: Annotated[str, typer.Option("--id", help="记录 ID")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, meta = _scada_mapping(kind.value, "get")
|
||||
params = {meta["id_param"]: id}
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 数据成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@data_scada_app.command("list")
|
||||
def data_scada_list(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ScadaListKind, typer.Option("--kind", help="SCADA 类型,仅支持 info")],
|
||||
) -> None:
|
||||
runtime = runtime_context(ctx)
|
||||
path, _ = _scada_mapping(kind.value, "list")
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取 SCADA 列表成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
require_auth=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="/network-schemas/scheme",
|
||||
require_auth=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="/schemes/detail",
|
||||
params={"schema_name": name},
|
||||
require_auth=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="/schemes",
|
||||
require_auth=True,
|
||||
)
|
||||
@@ -1,229 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .apps import component_option_app, network_app
|
||||
from .common import emit_api
|
||||
from .core import CLIError
|
||||
from .option_types import ComponentOptionKind
|
||||
|
||||
|
||||
@network_app.command("get-junction-properties")
|
||||
def network_get_junction_properties(
|
||||
ctx: typer.Context,
|
||||
junction: Annotated[str, typer.Option("--junction", help="节点 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取节点属性成功",
|
||||
method="GET",
|
||||
path="/junctions/properties",
|
||||
params={"junction": junction},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-pipe-properties")
|
||||
def network_get_pipe_properties(
|
||||
ctx: typer.Context,
|
||||
pipe: Annotated[str, typer.Option("--pipe", help="管道 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取管道属性成功",
|
||||
method="GET",
|
||||
path="/pipes/properties",
|
||||
params={"pipe": pipe},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pipes-properties")
|
||||
def network_get_all_pipes_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部管道属性成功",
|
||||
method="GET",
|
||||
path="/pipes",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-reservoir-properties")
|
||||
def network_get_reservoir_properties(
|
||||
ctx: typer.Context,
|
||||
reservoir: Annotated[str, typer.Option("--reservoir", help="水库 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水库属性成功",
|
||||
method="GET",
|
||||
path="/reservoirs/properties",
|
||||
params={"reservoir": reservoir},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-reservoirs-properties")
|
||||
def network_get_all_reservoir_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水库属性成功",
|
||||
method="GET",
|
||||
path="/reservoirs",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-tank-properties")
|
||||
def network_get_tank_properties(
|
||||
ctx: typer.Context,
|
||||
tank: Annotated[str, typer.Option("--tank", help="水箱 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水箱属性成功",
|
||||
method="GET",
|
||||
path="/tanks/properties",
|
||||
params={"tank": tank},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-tanks-properties")
|
||||
def network_get_all_tank_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水箱属性成功",
|
||||
method="GET",
|
||||
path="/tanks",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-pump-properties")
|
||||
def network_get_pump_properties(
|
||||
ctx: typer.Context,
|
||||
pump: Annotated[str, typer.Option("--pump", help="水泵 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取水泵属性成功",
|
||||
method="GET",
|
||||
path="/pumps/properties",
|
||||
params={"pump": pump},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-pumps-properties")
|
||||
def network_get_all_pump_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部水泵属性成功",
|
||||
method="GET",
|
||||
path="/pumps",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-valve-properties")
|
||||
def network_get_valve_properties(
|
||||
ctx: typer.Context,
|
||||
valve: Annotated[str, typer.Option("--valve", help="阀门 ID")],
|
||||
) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取阀门属性成功",
|
||||
method="GET",
|
||||
path="/valves/properties",
|
||||
params={"valve": valve},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@network_app.command("get-all-valves-properties")
|
||||
def network_get_all_valve_properties(ctx: typer.Context) -> None:
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取全部阀门属性成功",
|
||||
method="GET",
|
||||
path="/valves",
|
||||
params={},
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@component_option_app.command("schema")
|
||||
def component_option_schema(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
path = _component_option_path(kind.value, schema=True)
|
||||
params: dict[str, str] = {}
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY and pump:
|
||||
params["pump"] = pump
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取选项 schema 成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
@component_option_app.command("get")
|
||||
def component_option_get(
|
||||
ctx: typer.Context,
|
||||
kind: Annotated[ComponentOptionKind, typer.Option("--kind", help="选项类型,仅支持 time|energy|pump-energy|network")],
|
||||
pump: Annotated[str | None, typer.Option("--pump", help="pump-energy 时需要的泵 ID")] = None,
|
||||
) -> None:
|
||||
path = _component_option_path(kind.value, schema=False)
|
||||
params: dict[str, str] = {}
|
||||
if kind == ComponentOptionKind.PUMP_ENERGY:
|
||||
if not pump:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="PUMP_REQUIRED",
|
||||
message="--pump is required when --kind pump-energy",
|
||||
exit_code=2,
|
||||
)
|
||||
params["pump"] = pump
|
||||
emit_api(
|
||||
ctx,
|
||||
summary="读取选项属性成功",
|
||||
method="GET",
|
||||
path=path,
|
||||
params=params,
|
||||
require_auth=True,
|
||||
)
|
||||
|
||||
|
||||
def _component_option_path(kind: str, *, schema: bool) -> str:
|
||||
routes = {
|
||||
("time", True): "/network-schemas/time",
|
||||
("time", False): "/network-options/time",
|
||||
("energy", True): "/network-schemas/energy",
|
||||
("energy", False): "/network-options/energy",
|
||||
("pump-energy", True): "/network-schemas/pump-energy",
|
||||
("pump-energy", False): "/network-options/pump-energy",
|
||||
("network", True): "/network-schemas/option",
|
||||
("network", False): "/network-options",
|
||||
}
|
||||
path = routes.get((kind, schema))
|
||||
if path is None:
|
||||
raise CLIError(
|
||||
"CLI 参数错误",
|
||||
code="INVALID_KIND",
|
||||
message="--kind must be one of time, energy, pump-energy, network",
|
||||
exit_code=2,
|
||||
)
|
||||
return path
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
if not isinstance(obj, dict):
|
||||
obj = {}
|
||||
ctx.obj = obj
|
||||
|
||||
cached_runtime = obj.get("_runtime_context")
|
||||
if cached_runtime is not None:
|
||||
return cached_runtime
|
||||
|
||||
runtime = build_runtime_context(
|
||||
server=obj.get("server"),
|
||||
auth_stdin=obj.get("auth_stdin", False),
|
||||
scheme=obj.get("scheme"),
|
||||
timeout=obj.get("timeout", DEFAULT_TIMEOUT),
|
||||
request_id=obj.get("request_id"),
|
||||
)
|
||||
obj["_runtime_context"] = runtime
|
||||
return runtime
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
)
|
||||
emit_success(
|
||||
summary=summary,
|
||||
data=data,
|
||||
ctx=runtime,
|
||||
duration_ms=duration_ms,
|
||||
next_commands=next_commands,
|
||||
)
|
||||
@@ -1,602 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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"
|
||||
CLI_NAME = "tjwater-cli"
|
||||
DEFAULT_TIMEOUT = 180
|
||||
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
|
||||
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 _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_stdin: bool = False) -> AuthContext:
|
||||
if auth_stdin:
|
||||
raw = json.loads(sys.stdin.read())
|
||||
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"),
|
||||
"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"),
|
||||
headers={str(key): str(value) for key, value in headers.items()},
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_context(
|
||||
*,
|
||||
server: str | None,
|
||||
auth_stdin: bool = False,
|
||||
scheme: str | None,
|
||||
timeout: int,
|
||||
request_id: str | None,
|
||||
) -> RuntimeContext:
|
||||
auth = load_auth_context(auth_stdin=auth_stdin)
|
||||
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=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"],
|
||||
)
|
||||
|
||||
|
||||
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 auth context"],
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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 _prepare_public_request(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None,
|
||||
json_body: Any,
|
||||
) -> tuple[str, str, dict[str, Any] | None, Any]:
|
||||
return method.upper(), path.rstrip("/") or "/", params or None, json_body
|
||||
|
||||
|
||||
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,
|
||||
) -> tuple[Any, int]:
|
||||
require_server(ctx)
|
||||
method, path, params, json_body = _prepare_public_request(
|
||||
method,
|
||||
path,
|
||||
params,
|
||||
json_body,
|
||||
)
|
||||
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,
|
||||
) -> tuple[bytes, int]:
|
||||
require_server(ctx)
|
||||
method, path, params, _ = _prepare_public_request(
|
||||
method,
|
||||
path,
|
||||
params,
|
||||
None,
|
||||
)
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import typer.core
|
||||
|
||||
|
||||
class TJWaterGroup(typer.core.TyperGroup):
|
||||
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
||||
super().format_help(ctx, formatter)
|
||||
from .helping import build_group_help_appendix
|
||||
|
||||
appendix = build_group_help_appendix(ctx)
|
||||
if appendix:
|
||||
formatter.write_paragraph()
|
||||
formatter.write_text(appendix)
|
||||
@@ -1,414 +0,0 @@
|
||||
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-cli")
|
||||
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-cli", *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] = {
|
||||
(("component", "option", "schema"), "kind"): "time",
|
||||
(("component", "option", "get"), "kind"): "time",
|
||||
(("data", "timeseries", "composite"), "kind"): "scada-simulation",
|
||||
(("data", "scada", "get"), "kind"): "info",
|
||||
(("data", "scada", "list"), "kind"): "info",
|
||||
}
|
||||
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_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
|
||||
if has_required_options:
|
||||
return example
|
||||
parts = ["tjwater-cli", *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-cli {' '.join(path)} help"
|
||||
command_item["example"] = f"tjwater-cli {' '.join(path)} help"
|
||||
else:
|
||||
existing_examples = [] if doc is None else list(doc.get("examples", []))
|
||||
command_item["usage"] = build_usage(path) or command_item.get("usage")
|
||||
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]) -> None:
|
||||
typer.echo(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
|
||||
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-cli {' '.join(group_path)} help",
|
||||
"examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||
},
|
||||
merge_next_commands(
|
||||
[f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"],
|
||||
["tjwater-cli 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-cli help {' '.join(command_path)}"], ["tjwater-cli help"]),
|
||||
)
|
||||
if payload is not None and is_index:
|
||||
return (
|
||||
{
|
||||
"command_group": " ".join(command_path),
|
||||
"usage": f"tjwater-cli {' '.join(command_path)} help",
|
||||
"examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||
},
|
||||
merge_next_commands(
|
||||
[f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"],
|
||||
["tjwater-cli help"],
|
||||
),
|
||||
)
|
||||
return ({"usage": usage} if usage else None, ["tjwater-cli 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 _build_root_help_epilog() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"\b",
|
||||
"Examples:",
|
||||
" tjwater-cli help",
|
||||
" tjwater-cli help simulation run",
|
||||
" tjwater-cli simulation run --help",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||
lines = ["\b"]
|
||||
description = payload.get("description")
|
||||
usage = payload.get("usage")
|
||||
examples = payload.get("examples", [])
|
||||
next_commands = payload.get("next_commands", [])
|
||||
if description:
|
||||
lines.extend([f"Description: {description}", ""])
|
||||
if usage:
|
||||
lines.extend([f"Usage example: {usage}", ""])
|
||||
if examples:
|
||||
lines.append("Examples:")
|
||||
lines.extend(f" {example}" for example in examples)
|
||||
lines.append("")
|
||||
if next_commands:
|
||||
lines.append("Next steps:")
|
||||
lines.extend(f" {command}" for command in next_commands)
|
||||
lines.append("")
|
||||
lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str:
|
||||
lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"]
|
||||
for command in payload.get("commands", [])[:2]:
|
||||
example = command.get("example")
|
||||
if example:
|
||||
lines.append(f" {example}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_group_help_appendix(click_ctx: click.Context | None) -> str | None:
|
||||
path = context_command_path(click_ctx)
|
||||
if not path:
|
||||
return _build_root_help_epilog()
|
||||
payload, is_index = resolve_help_payload(path)
|
||||
if payload is None or not is_index:
|
||||
return None
|
||||
return _build_group_help_epilog(path, payload)
|
||||
|
||||
|
||||
def make_group_help_handler(path_prefix: tuple[str, ...]):
|
||||
def group_help() -> 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-cli help"],
|
||||
)
|
||||
emit_help_payload(payload)
|
||||
|
||||
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 = "\n".join(
|
||||
[
|
||||
"TJWater agent CLI",
|
||||
"",
|
||||
"Examples:",
|
||||
" tjwater-cli help",
|
||||
" tjwater-cli help simulation run",
|
||||
" tjwater-cli simulation run --help",
|
||||
]
|
||||
)
|
||||
app.short_help = "TJWater agent CLI"
|
||||
for group_app, path_prefix in GROUP_HELP_APPS:
|
||||
for command_info in group_app.registered_commands:
|
||||
command_path = (*path_prefix, command_info.name)
|
||||
if command_info.name == "help":
|
||||
command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。"
|
||||
command_info.short_help = command_info.help
|
||||
command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"])
|
||||
command_info.hidden = False
|
||||
continue
|
||||
payload = get_command_doc(command_path)
|
||||
command_info.help = None if payload is None else str(payload.get("summary", ""))
|
||||
command_info.short_help = command_info.help
|
||||
command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload)
|
||||
command_info.hidden = is_hidden_path(command_path)
|
||||
for group_info in group_app.registered_groups:
|
||||
group_path = (*path_prefix, group_info.name)
|
||||
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)
|
||||
@@ -1,112 +0,0 @@
|
||||
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_readonly # 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_stdin: Annotated[bool, typer.Option("--auth-stdin", help="从标准输入读取认证上下文 JSON")] = False,
|
||||
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_stdin": auth_stdin,
|
||||
"scheme": scheme,
|
||||
"timeout": timeout,
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
|
||||
register_group_help_commands()
|
||||
|
||||
|
||||
@app.command("help", context_settings={"allow_extra_args": True})
|
||||
def help_command(ctx: typer.Context) -> None:
|
||||
command_path = list(ctx.args)
|
||||
payload, is_index = resolve_help_payload(tuple(command_path))
|
||||
if payload is None:
|
||||
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-cli help <command-path>",
|
||||
"examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"],
|
||||
},
|
||||
next_commands=["tjwater-cli help", "tjwater-cli help simulation"],
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
emit_help_payload(payload)
|
||||
|
||||
|
||||
# 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-cli", 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())
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ElementType(str, Enum):
|
||||
PIPE = "pipe"
|
||||
JUNCTION = "junction"
|
||||
|
||||
|
||||
class SimulationQuery(str, Enum):
|
||||
BY_ID_TIME = "by-id-time"
|
||||
BY_SCHEME_TIME_PROPERTY = "by-scheme-time-property"
|
||||
|
||||
|
||||
class CompositeKind(str, Enum):
|
||||
SCADA_SIMULATION = "scada-simulation"
|
||||
ELEMENT_SIMULATION = "element-simulation"
|
||||
ELEMENT_SCADA = "element-scada"
|
||||
|
||||
|
||||
class ComponentOptionKind(str, Enum):
|
||||
TIME = "time"
|
||||
ENERGY = "energy"
|
||||
PUMP_ENERGY = "pump-energy"
|
||||
NETWORK = "network"
|
||||
|
||||
|
||||
class ValveMode(str, Enum):
|
||||
CLOSE = "close"
|
||||
ISOLATION = "isolation"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
MONITORING = "monitoring"
|
||||
SIMULATION = "simulation"
|
||||
|
||||
|
||||
class ScadaListKind(str, Enum):
|
||||
INFO = "info"
|
||||
|
||||
|
||||
PIPE_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
)
|
||||
|
||||
JUNCTION_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"actual_demand",
|
||||
"total_head",
|
||||
"pressure",
|
||||
"quality",
|
||||
)
|
||||
|
||||
SCADA_TIMESERIES_FIELDS: tuple[str, ...] = (
|
||||
"monitored_value",
|
||||
"cleaned_value",
|
||||
)
|
||||
|
||||
|
||||
def timeseries_fields_for_element_type(element_type: ElementType) -> tuple[str, ...]:
|
||||
if element_type == ElementType.PIPE:
|
||||
return PIPE_TIMESERIES_FIELDS
|
||||
if element_type == ElementType.JUNCTION:
|
||||
return JUNCTION_TIMESERIES_FIELDS
|
||||
raise AssertionError(f"unreachable element type: {element_type}")
|
||||
@@ -1,626 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .core import CommandDoc, CommandOptionDoc, SCHEMA_VERSION
|
||||
|
||||
GROUP_SUMMARIES: dict[tuple[str, ...], str] = {
|
||||
("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"): "方案数据查询命令。",
|
||||
}
|
||||
|
||||
HIDDEN_PATH_PREFIXES: tuple[tuple[str, ...], ...] = (
|
||||
("analysis", "burst-location"),
|
||||
("analysis", "risk"),
|
||||
)
|
||||
|
||||
COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
|
||||
("network", "get-junction-properties"): CommandDoc(
|
||||
path=("network", "get-junction-properties"),
|
||||
summary="读取节点属性",
|
||||
description="调用 GET /api/v1/junctions/{junction_id}/properties。",
|
||||
options=(CommandOptionDoc("junction", "节点 ID", required=True),),
|
||||
examples=("tjwater-cli network get-junction-properties --junction J1",),
|
||||
),
|
||||
("network", "get-pipe-properties"): CommandDoc(
|
||||
path=("network", "get-pipe-properties"),
|
||||
summary="读取管道属性",
|
||||
description="调用 GET /api/v1/pipes/{pipe_id}/properties。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pipe-properties --pipe P1",),
|
||||
),
|
||||
("network", "get-all-pipes-properties"): CommandDoc(
|
||||
path=("network", "get-all-pipes-properties"),
|
||||
summary="读取全部管道属性",
|
||||
description="调用 GET /api/v1/pipes/properties。",
|
||||
examples=("tjwater-cli network get-all-pipes-properties",),
|
||||
),
|
||||
("network", "get-reservoir-properties"): CommandDoc(
|
||||
path=("network", "get-reservoir-properties"),
|
||||
summary="读取水库属性",
|
||||
description="调用 GET /api/v1/reservoirs/{reservoir_id}/properties。",
|
||||
options=(CommandOptionDoc("reservoir", "水库 ID", required=True),),
|
||||
examples=("tjwater-cli network get-reservoir-properties --reservoir R1",),
|
||||
),
|
||||
("network", "get-all-reservoirs-properties"): CommandDoc(
|
||||
path=("network", "get-all-reservoirs-properties"),
|
||||
summary="读取全部水库属性",
|
||||
description="调用 GET /api/v1/reservoirs/properties。",
|
||||
examples=("tjwater-cli network get-all-reservoirs-properties",),
|
||||
),
|
||||
("network", "get-tank-properties"): CommandDoc(
|
||||
path=("network", "get-tank-properties"),
|
||||
summary="读取水箱属性",
|
||||
description="调用 GET /api/v1/tanks/{tank_id}/properties。",
|
||||
options=(CommandOptionDoc("tank", "水箱 ID", required=True),),
|
||||
examples=("tjwater-cli network get-tank-properties --tank T1",),
|
||||
),
|
||||
("network", "get-all-tanks-properties"): CommandDoc(
|
||||
path=("network", "get-all-tanks-properties"),
|
||||
summary="读取全部水箱属性",
|
||||
description="调用 GET /api/v1/tanks/properties。",
|
||||
examples=("tjwater-cli network get-all-tanks-properties",),
|
||||
),
|
||||
("network", "get-pump-properties"): CommandDoc(
|
||||
path=("network", "get-pump-properties"),
|
||||
summary="读取水泵属性",
|
||||
description="调用 GET /api/v1/pumps/{pump_id}/properties。",
|
||||
options=(CommandOptionDoc("pump", "水泵 ID", required=True),),
|
||||
examples=("tjwater-cli network get-pump-properties --pump PU1",),
|
||||
),
|
||||
("network", "get-all-pumps-properties"): CommandDoc(
|
||||
path=("network", "get-all-pumps-properties"),
|
||||
summary="读取全部水泵属性",
|
||||
description="调用 GET /api/v1/pumps/properties。",
|
||||
examples=("tjwater-cli network get-all-pumps-properties",),
|
||||
),
|
||||
("network", "get-valve-properties"): CommandDoc(
|
||||
path=("network", "get-valve-properties"),
|
||||
summary="读取阀门属性",
|
||||
description="调用 GET /api/v1/valves/{valve_id}/properties。",
|
||||
options=(CommandOptionDoc("valve", "阀门 ID", required=True),),
|
||||
examples=("tjwater-cli network get-valve-properties --valve V1",),
|
||||
),
|
||||
("network", "get-all-valves-properties"): CommandDoc(
|
||||
path=("network", "get-all-valves-properties"),
|
||||
summary="读取全部阀门属性",
|
||||
description="调用 GET /api/v1/valves/properties。",
|
||||
examples=("tjwater-cli network get-all-valves-properties",),
|
||||
),
|
||||
("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"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli component option schema --kind time",
|
||||
"tjwater-cli component option schema --kind energy",
|
||||
"tjwater-cli component option schema --kind pump-energy --pump PUMP1",
|
||||
"tjwater-cli component option schema --kind network",
|
||||
),
|
||||
),
|
||||
("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"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli component option get --kind time",
|
||||
"tjwater-cli component option get --kind energy",
|
||||
"tjwater-cli component option get --kind pump-energy --pump PUMP1",
|
||||
"tjwater-cli component option get --kind network",
|
||||
),
|
||||
),
|
||||
("simulation", "run"): CommandDoc(
|
||||
path=("simulation", "run"),
|
||||
summary="触发指定绝对时间的模拟运行",
|
||||
description="把显式带时区的 RFC3339 start-time 直接传给 POST /api/v1/simulation-runs;服务端按带时区时间处理并统一按 UTC 存储结果,实时数据需后续通过 data timeseries 在对应时间段查询。duration 单位为分钟。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续分钟数", required=True),
|
||||
),
|
||||
examples=("tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30",),
|
||||
next_commands=(
|
||||
"tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
|
||||
"tjwater-cli 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[] 并调用 POST /api/v1/burst-analyses;接口本身只返回分析执行结果,方案数据需后续通过 data scheme 命令获取。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
CommandOptionDoc("burst-file", "爆管输入 JSON 文件", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01",
|
||||
"tjwater-cli data scheme get --name burst_case_01",
|
||||
"tjwater-cli data scheme list",
|
||||
),
|
||||
),
|
||||
("analysis", "valve"): CommandDoc(
|
||||
path=("analysis", "valve"),
|
||||
summary="阀门工况分析。",
|
||||
description="close 模式按指定阀门关闭执行定时长模拟;isolation 模式按指定事故元素计算关阀隔离方案。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc(name="mode", description="阀门操作模式:'close' 或 'isolation'", required=True),
|
||||
CommandOptionDoc(name="start-time", description="close 模式需要的起始绝对时间,必须显式带时区偏移"),
|
||||
CommandOptionDoc(name="valve", description="close 模式下需关闭的阀门 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="element", description="isolation 模式下的事故元素 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="disabled-valve", description="isolation 模式下需排除的故障阀门 ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc(name="duration", description="close 模式持续秒数,默认 900"),
|
||||
CommandOptionDoc(name="scheme", description="close 模式方案名称"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01",
|
||||
"tjwater-cli analysis valve --mode isolation --element E1 --element E2",
|
||||
"tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3",
|
||||
),
|
||||
),
|
||||
("analysis", "flushing"): CommandDoc(
|
||||
path=("analysis", "flushing"),
|
||||
summary="执行冲洗分析",
|
||||
description="读取 valve-setting-file 并转换为 valves[] / valves_k[]。duration 单位为秒,默认 900。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("valve-setting-file", "阀门开度 JSON 文件", required=True),
|
||||
CommandOptionDoc("drainage-node", "排污节点 ID", required=True),
|
||||
CommandOptionDoc("flow", "冲洗流量", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数,默认 900"),
|
||||
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01",),
|
||||
),
|
||||
("analysis", "age"): CommandDoc(
|
||||
path=("analysis", "age"),
|
||||
summary="执行水龄分析",
|
||||
description="调用 POST /api/v1/water-age-analyses。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900",),
|
||||
),
|
||||
("analysis", "contaminant"): CommandDoc(
|
||||
path=("analysis", "contaminant"),
|
||||
summary="执行污染物模拟",
|
||||
description="调用 POST /api/v1/contaminant-simulations。duration 单位为秒。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("duration", "持续秒数", required=True),
|
||||
CommandOptionDoc("source-node", "污染源节点 ID", required=True),
|
||||
CommandOptionDoc("concentration", "浓度值", required=True),
|
||||
CommandOptionDoc("pattern", "模式 ID"),
|
||||
CommandOptionDoc("scheme", "方案名称", required=True),
|
||||
),
|
||||
examples=("tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01",),
|
||||
),
|
||||
("analysis", "sensor-placement", "kmeans"): CommandDoc(
|
||||
path=("analysis", "sensor-placement", "kmeans"),
|
||||
summary="执行 KMeans 传感器选址",
|
||||
description="使用 POST /pressure_sensor_placement_kmeans/,补齐 username 和 min_diameter。",
|
||||
options=(
|
||||
CommandOptionDoc("count", "传感器数量", required=True),
|
||||
CommandOptionDoc("min-diameter", "最小管径,默认 0"),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01",),
|
||||
),
|
||||
("analysis", "leakage", "identify"): CommandDoc(
|
||||
path=("analysis", "leakage", "identify"),
|
||||
summary="执行漏损识别",
|
||||
description="把 CLI 时间映射到 scada_start / scada_end。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01",),
|
||||
),
|
||||
("analysis", "leakage", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "list"),
|
||||
summary="列出漏损方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=dma_leak_identification。",
|
||||
examples=("tjwater-cli analysis leakage schemes list",),
|
||||
),
|
||||
("analysis", "leakage", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "leakage", "schemes", "get"),
|
||||
summary="读取漏损方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=dma_leak_identification。",
|
||||
examples=("tjwater-cli analysis leakage schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "burst-detection", "detect"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "detect"),
|
||||
summary="执行爆管检测",
|
||||
description="调用 POST /api/v1/burst-detections。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
),
|
||||
examples=("tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01",),
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "list"),
|
||||
summary="列出爆管检测方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_detection。",
|
||||
examples=("tjwater-cli analysis burst-detection schemes list",),
|
||||
),
|
||||
("analysis", "burst-detection", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-detection", "schemes", "get"),
|
||||
summary="读取爆管检测方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_detection。",
|
||||
examples=("tjwater-cli analysis burst-detection schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "burst-location", "locate"): CommandDoc(
|
||||
path=("analysis", "burst-location", "locate"),
|
||||
summary="执行爆管定位",
|
||||
description="调用 POST /api/v1/burst-locations;需要 burst-leakage。支持 monitoring 和 simulation 两种数据源。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的 SCADA 开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的 SCADA 结束时间", required=True),
|
||||
CommandOptionDoc("burst-leakage", "爆管漏水量", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("data-source", "数据源:monitoring(默认)或 simulation"),
|
||||
CommandOptionDoc("pressure-scada-id", "压力 SCADA ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc("flow-scada-id", "流量 SCADA ID(可多次指定)", repeated=True),
|
||||
CommandOptionDoc("pressure-file", "包含 burst_pressure/normal_pressure 的 JSON 文件"),
|
||||
CommandOptionDoc("flow-file", "包含 burst_flow/normal_flow 的 JSON 文件"),
|
||||
CommandOptionDoc("use-scada-flow", "启用 SCADA 流量"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01",
|
||||
"tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 50.0 --scheme locate_case_01 --data-source simulation --pressure-file ./pressure.json --flow-file ./flow.json",
|
||||
),
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "list"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "list"),
|
||||
summary="列出爆管定位方案",
|
||||
description="调用 GET /api/v1/schemes,并传入 scheme_type=burst_location。",
|
||||
examples=("tjwater-cli analysis burst-location schemes list",),
|
||||
),
|
||||
("analysis", "burst-location", "schemes", "get"): CommandDoc(
|
||||
path=("analysis", "burst-location", "schemes", "get"),
|
||||
summary="读取爆管定位方案详情",
|
||||
description="调用 GET /api/v1/schemes/{scheme_name},并传入 scheme_type=burst_location。",
|
||||
examples=("tjwater-cli analysis burst-location schemes get my_scheme",),
|
||||
),
|
||||
("analysis", "risk", "pipe-now"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-now"),
|
||||
summary="读取单条管道当前风险",
|
||||
description="调用 GET /api/v1/pipes/risk-probability-now。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli analysis risk pipe-now --pipe P1",),
|
||||
),
|
||||
("analysis", "risk", "pipe-history"): CommandDoc(
|
||||
path=("analysis", "risk", "pipe-history"),
|
||||
summary="读取单条管道历史风险",
|
||||
description="调用 GET /api/v1/pipes/risk-probability。",
|
||||
options=(CommandOptionDoc("pipe", "管道 ID", required=True),),
|
||||
examples=("tjwater-cli analysis risk pipe-history --pipe P1",),
|
||||
),
|
||||
("analysis", "risk", "network"): CommandDoc(
|
||||
path=("analysis", "risk", "network"),
|
||||
summary="读取全网风险",
|
||||
description="组合 /getnetworkpiperiskprobabilitynow/ 与 /getpiperiskprobabilitygeometries/。",
|
||||
examples=("tjwater-cli analysis risk network",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "links"),
|
||||
summary="查询实时管道时序",
|
||||
description="调用 GET /api/v1/timeseries/realtime/links。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "nodes"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "nodes"),
|
||||
summary="查询实时节点时序",
|
||||
description="调用 GET /api/v1/timeseries/realtime/nodes。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-id-time"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-id-time"),
|
||||
summary="按元素和时间查询实时模拟结果",
|
||||
description="调用 GET /api/v1/timeseries/realtime/by-element。",
|
||||
options=(
|
||||
CommandOptionDoc("id", "元素 ID", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00",
|
||||
"tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "realtime", "simulation-by-time-property"): CommandDoc(
|
||||
path=("data", "timeseries", "realtime", "simulation-by-time-property"),
|
||||
summary="按时间和属性查询实时模拟结果",
|
||||
description="调用 GET /api/v1/timeseries/realtime/by-property。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值", required=True),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("property", "属性名;会按 type 校验可选值", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "links"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "links"),
|
||||
summary="查询方案管道时序",
|
||||
description="调用 GET /api/v1/timeseries/schemes/links。",
|
||||
options=(
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "node-field"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "node-field"),
|
||||
summary="查询方案节点字段时序",
|
||||
description="调用 GET /api/v1/timeseries/schemes/nodes/{node_id}/{field}。field 仅支持 actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("node", "节点 ID", required=True),
|
||||
CommandOptionDoc("field", "字段名:actual_demand、total_head、pressure、quality", required=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",),
|
||||
),
|
||||
("data", "timeseries", "scheme", "simulation"): CommandDoc(
|
||||
path=("data", "timeseries", "scheme", "simulation"),
|
||||
summary="查询方案模拟数据",
|
||||
description="支持 by-id-time 与 by-scheme-time-property 两种查询。pipe 属性:flow、friction、headloss、quality、reaction、setting、status、velocity;junction 属性:actual_demand、total_head、pressure、quality。",
|
||||
options=(
|
||||
CommandOptionDoc("query", "查询模式:by-id-time 或 by-scheme-time-property", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
CommandOptionDoc("id", "元素 ID(by-id-time 时必需)"),
|
||||
CommandOptionDoc("time", "显式带时区的查询时间", required=True),
|
||||
CommandOptionDoc("type", "元素类型:pipe 或 junction;links/nodes 是独立子命令,不是 type 取值"),
|
||||
CommandOptionDoc("property", "属性名(by-scheme-time-property 时必需;会按 type 校验可选值)"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme",
|
||||
"tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "scada", "query"): CommandDoc(
|
||||
path=("data", "timeseries", "scada", "query"),
|
||||
summary="查询 SCADA 时序",
|
||||
description="device-id 会被转换成后端逗号分隔参数。field 仅支持 monitored_value、cleaned_value。",
|
||||
options=(
|
||||
CommandOptionDoc("device-id", "设备 ID(可多次指定)", required=True, repeated=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("field", "字段名:monitored_value、cleaned_value"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",
|
||||
"tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "composite"): CommandDoc(
|
||||
path=("data", "timeseries", "composite"),
|
||||
summary="执行复合时序查询",
|
||||
description="kind 支持 scada-simulation、element-simulation、element-scada。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "复合查询类型", required=True),
|
||||
CommandOptionDoc("feature", "特征值(可多次指定,scada-simulation 为 device_id,element-simulation 为 element_id:property,element-scada 为 element_id)", repeated=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
CommandOptionDoc("scheme", "方案名称"),
|
||||
CommandOptionDoc("scheme-type", "方案类型"),
|
||||
CommandOptionDoc("use-cleaned", "element-scada 使用清洗值"),
|
||||
),
|
||||
examples=(
|
||||
"tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||
"tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme",
|
||||
"tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned",
|
||||
),
|
||||
),
|
||||
("data", "timeseries", "composite", "pipeline-health"): CommandDoc(
|
||||
path=("data", "timeseries", "composite", "pipeline-health"),
|
||||
summary="查询管道健康预测",
|
||||
description="调用 GET /api/v1/pipeline-health-predictions。",
|
||||
options=(
|
||||
CommandOptionDoc("pipe", "管道 ID", required=True),
|
||||
CommandOptionDoc("start-time", "显式带时区的开始时间", required=True),
|
||||
CommandOptionDoc("end-time", "显式带时区的结束时间", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00",),
|
||||
),
|
||||
("data", "scada", "get"): CommandDoc(
|
||||
path=("data", "scada", "get"),
|
||||
summary="读取单条 SCADA 元数据",
|
||||
description="kind 仅支持 info。",
|
||||
options=(
|
||||
CommandOptionDoc("kind", "SCADA 数据类型", required=True),
|
||||
CommandOptionDoc("id", "记录 ID", required=True),
|
||||
),
|
||||
examples=("tjwater-cli data scada get --kind info --id SCADA-001",),
|
||||
),
|
||||
("data", "scada", "list"): CommandDoc(
|
||||
path=("data", "scada", "list"),
|
||||
summary="列出 SCADA 元数据",
|
||||
description="kind 仅支持 info。",
|
||||
options=(CommandOptionDoc("kind", "SCADA 数据类型", required=True),),
|
||||
examples=("tjwater-cli data scada list --kind info",),
|
||||
),
|
||||
("data", "scheme", "schema"): CommandDoc(
|
||||
path=("data", "scheme", "schema"),
|
||||
summary="读取方案 schema",
|
||||
description="调用 GET /api/v1/network-schemas/scheme。",
|
||||
examples=("tjwater-cli data scheme schema",),
|
||||
),
|
||||
("data", "scheme", "get"): CommandDoc(
|
||||
path=("data", "scheme", "get"),
|
||||
summary="读取单条方案",
|
||||
description="调用 GET /api/v1/schemes/detail。",
|
||||
options=(CommandOptionDoc("name", "方案名称", required=True),),
|
||||
examples=("tjwater-cli data scheme get --name my_scheme",),
|
||||
),
|
||||
("data", "scheme", "list"): CommandDoc(
|
||||
path=("data", "scheme", "list"),
|
||||
summary="列出方案",
|
||||
description="调用 GET /api/v1/schemes。",
|
||||
examples=("tjwater-cli data scheme list",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc))
|
||||
commands.append(
|
||||
{
|
||||
"command": " ".join(current_path),
|
||||
"summary": get_group_summary(current_path) if is_group else doc.summary,
|
||||
"usage": usage,
|
||||
"example": f"tjwater-cli {' '.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-cli", *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)
|
||||
@@ -1,423 +0,0 @@
|
||||
# Agent CLI 接口范围确认
|
||||
|
||||
本文档确认 `app/api/v1/endpoints/` 面向 Agent CLI 的首批封装范围。
|
||||
|
||||
## 结论
|
||||
|
||||
首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。
|
||||
|
||||
```text
|
||||
tjwater-cli network
|
||||
tjwater-cli component
|
||||
tjwater-cli simulation
|
||||
tjwater-cli analysis
|
||||
tjwater-cli data
|
||||
tjwater-cli help
|
||||
```
|
||||
|
||||
首批默认不暴露:
|
||||
|
||||
- 会修改 network 的接口:`add*`、`set*`、`delete*`、`generate*`
|
||||
- 项目生命周期接口:创建、删除、导入、打开、关闭、锁定、解锁、复制
|
||||
- 数据写入/清理接口:insert、update、delete、clean、clear、batch store
|
||||
- 用户管理接口:创建、更新、删除、激活、停用
|
||||
- 快照回滚和批量命令执行接口:undo、redo、pick、batch
|
||||
|
||||
## 设计原则
|
||||
|
||||
- CLI 不按 HTTP endpoint 一比一映射,而按 Agent 任务组织。
|
||||
- 首批只暴露 `schema`、`list`、`get`、`exists`、只读计算和分析类能力。
|
||||
- CLI 输入优先使用显式选项、可重复选项、枚举值和文件路径,尽量不要求用户直接输入 JSON。
|
||||
- CLI 输出统一使用 JSON;首批默认直接在 stdout 返回结构化结果,不再额外设计 `result_ref` / `--out-ref` 输出层。
|
||||
- 首批 CLI 只保留 **Non-interactive / Agent** 认证模式:必须显式注入认证上下文,不隐式复用本机默认登录态,也不设计本地 `login`。
|
||||
- stdout/stderr、退出码、输出 schema version 视为 CLI 契约的一部分,需要独立于 HTTP body 明确定义。
|
||||
- 现有 HTTP 路径的拼写错误、双斜杠、错误方法不继承到 CLI。
|
||||
- 高频命令可以提供 alias,但文档和 skill 只写规范命令。
|
||||
|
||||
## 分级约束
|
||||
|
||||
| 顶层命令 | 二级范围 | 说明 |
|
||||
|---|---|---|
|
||||
| `network` | `get-node-properties`、`get-link-properties` | 管网节点/管线属性查询,只读 |
|
||||
| `component` | `option` | EPANET 选项设置,只读 |
|
||||
| `simulation` | `run` | 模拟运行 |
|
||||
| `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 |
|
||||
| `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 |
|
||||
| `help` | `COMMAND` | Agent 能力发现和命令说明 |
|
||||
|
||||
命令深度建议:
|
||||
|
||||
- 常规命令不超过 3 层:`tjwater-cli component option get`
|
||||
- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links`
|
||||
- `risk` 归入 `analysis risk`
|
||||
- `scada`、`scheme`、`extension` 归入 `data`
|
||||
|
||||
## 全局上下文与通用参数
|
||||
|
||||
首批 CLI 建议统一支持以下全局参数:
|
||||
|
||||
```text
|
||||
--server URL
|
||||
--auth-context PATH
|
||||
--scheme SCHEME
|
||||
--timeout SEC
|
||||
--request-id ID
|
||||
```
|
||||
|
||||
参数含义:
|
||||
|
||||
| 参数 | 含义 | 作用域 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--server URL` | 指定 CLI 要连接的服务端地址 | 连接上下文 | 例如 `https://api.example.com`。用于覆盖环境变量或 `auth-context` 中的默认 base URL,便于在 dev / test / prod 间切换。 |
|
||||
| `--auth-context PATH` | 指定一份显式的隔离认证上下文文件 | 认证上下文 | 面向 agent / 自动化调用。该文件可包含 access token、server、project、user 等字段;不得隐式回退到本机默认状态。 |
|
||||
| `--scheme SCHEME` | 指定当前命令使用的方案 / 工况 / 配置集标识 | 业务资源上下文 | 适用于时序方案、检测方案、定位方案等场景。用于区分当前 project 下的不同分析配置。 |
|
||||
| `--timeout SEC` | 指定本次命令等待响应的超时时间 | 执行控制 | 对同步请求表示请求超时上限,超过后 CLI 直接返回超时错误。 |
|
||||
| `--request-id ID` | 为本次调用显式指定链路追踪 ID | 追踪与观测 | 便于跨前端、CLI、服务端串联日志与审计记录。若未提供,CLI 可自动生成,并应在输出 metadata 中回显。 |
|
||||
|
||||
约束:
|
||||
|
||||
- project 属于认证上下文的一部分,默认从 `auth-context` 或前端传入的 `X-Project-Id` 解析,不作为常规全局参数要求重复传入。
|
||||
- 首批 CLI 不提供 Interactive / Human 登录态;所有命令都按 Agent 模式处理,不得依赖隐式默认认证状态。
|
||||
- `--server`、`--auth-context` 属于连接与认证上下文;`--scheme` 属于业务资源上下文,两者需要分开建模。
|
||||
- `--request-id` 用于链路追踪;若未显式传入,CLI 可以自动生成,但必须在输出 metadata 中回显。
|
||||
|
||||
参数表达建议:
|
||||
|
||||
- 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。
|
||||
- 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。
|
||||
- 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。
|
||||
- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。
|
||||
|
||||
## 首批 CLI 范围
|
||||
|
||||
### Network
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/network/*.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 |
|
||||
| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 |
|
||||
| `tjwater-cli network get-all-junction-properties` | `GET /getalljunctionproperties/` | 读取当前 project 中所有节点属性 |
|
||||
| `tjwater-cli network get-all-pipe-properties` | `GET /getallpipeproperties/` | 读取当前 project 中所有管道属性 |
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
add*
|
||||
set*
|
||||
delete*
|
||||
generate*
|
||||
POST /generatedistrictmeteringarea/
|
||||
POST /generatesubdistrictmeteringarea/
|
||||
POST /generateservicearea/
|
||||
POST /generatevirtualdistrict/
|
||||
```
|
||||
|
||||
备注:`GET /settitle/` 语义是修改标题,首批不暴露。
|
||||
|
||||
### Component
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/components/*.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema |
|
||||
| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 |
|
||||
| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema |
|
||||
| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 |
|
||||
| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema |
|
||||
| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 |
|
||||
| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema |
|
||||
| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 |
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
POST /addcurve/
|
||||
POST /setcurveproperties/
|
||||
POST /deletecurve/
|
||||
POST /addpattern/
|
||||
POST /setpatternproperties/
|
||||
POST /deletepattern/
|
||||
POST /settimeproperties/
|
||||
POST /setenergyproperties/
|
||||
GET /setpumpenergyproperties//
|
||||
POST /setoptionproperties/
|
||||
POST /setcontrolproperties/
|
||||
POST /setruleproperties/
|
||||
POST /setqualityproperties/
|
||||
POST /setemitterproperties/
|
||||
POST /setsource/
|
||||
POST /addsource/
|
||||
POST /deletesource/
|
||||
POST /setreaction/
|
||||
POST /setpipereaction/
|
||||
POST /settankreaction/
|
||||
POST /setmixing/
|
||||
POST /addmixing/
|
||||
POST /deletemixing/
|
||||
POST /setvertexproperties/
|
||||
POST /addvertex/
|
||||
POST /deletevertex/
|
||||
POST /setlabelproperties/
|
||||
POST /addlabel/
|
||||
POST /deletelabel/
|
||||
POST /setbackdropproperties/
|
||||
```
|
||||
|
||||
备注:
|
||||
|
||||
- `options` 当前实际只读接口分为 4 组:`time`、`energy`、`pump-energy`、`network`。
|
||||
- `pump-energy` 是唯一需要额外资源标识的读取接口,必须带 `--pump PUMP`。
|
||||
- 后端现有路径 `GET /getpumpenergyproperties//` 和 `GET /setpumpenergyproperties//` 存在双斜杠 / 方法异常,CLI 不继承这些路径细节,只保留语义化命令。
|
||||
|
||||
### Simulation / Analysis / Risk
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/simulation.py
|
||||
app/api/v1/endpoints/leakage.py
|
||||
app/api/v1/endpoints/burst_detection.py
|
||||
app/api/v1/endpoints/burst_location.py
|
||||
app/api/v1/endpoints/risk.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /simulations/run-by-date` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 |
|
||||
| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst-analysis` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` |
|
||||
| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE [--scheme SCHEME]` | `GET /valve_close_analysis/`、`GET /valve-isolation-analysis` | 阀门分析;close 模式需要 `--scheme`,`--valve` 可重复 |
|
||||
| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW --scheme SCHEME [--duration SEC]` | `GET /flushing-analysis` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` |
|
||||
| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 |
|
||||
| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE --scheme SCHEME [--pattern PATTERN]` | `GET /contaminant-simulation` | 污染物模拟 |
|
||||
| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 |
|
||||
| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 |
|
||||
| `tjwater-cli analysis leakage schemes list\|get` | `GET /schemes?scheme_type=dma_leak_identification`、`GET /schemes/{scheme_name}?scheme_type=dma_leak_identification` | 漏损方案查询 |
|
||||
| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 |
|
||||
| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /schemes?scheme_type=burst_detection`、`GET /schemes/{scheme_name}?scheme_type=burst_detection` | 爆管检测方案查询 |
|
||||
| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 |
|
||||
| `tjwater-cli analysis burst-location schemes list\|get` | `GET /schemes?scheme_type=burst_location`、`GET /schemes/{scheme_name}?scheme_type=burst_location` | 爆管定位方案查询 |
|
||||
| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 |
|
||||
| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 |
|
||||
| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 |
|
||||
|
||||
暂缓或暂不暴露:
|
||||
|
||||
```text
|
||||
POST /network_project/
|
||||
GET /runproject/
|
||||
POST /network_update/
|
||||
POST /project_management/
|
||||
POST /sensorplacementscheme/create
|
||||
POST /pump_failure/
|
||||
POST /pressure_regulation/
|
||||
POST /scheduling_analysis/
|
||||
POST /daily_scheduling_analysis/
|
||||
```
|
||||
|
||||
执行模型:
|
||||
|
||||
- 首批 CLI 统一按同步命令设计,避免引入额外的异步轮询协议。
|
||||
- `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。
|
||||
- 当前 `simulations/run-by-date` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。
|
||||
- `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。
|
||||
- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。
|
||||
- `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。
|
||||
|
||||
### Data
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/timeseries/*.py
|
||||
app/api/v1/endpoints/scada.py
|
||||
app/api/v1/endpoints/schemes.py
|
||||
app/api/v1/endpoints/extension.py
|
||||
app/api/v1/endpoints/misc.py
|
||||
app/api/v1/endpoints/project_data.py
|
||||
```
|
||||
|
||||
| 命令 | 覆盖接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 |
|
||||
| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 |
|
||||
| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 |
|
||||
| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 |
|
||||
| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 |
|
||||
| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 |
|
||||
| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 |
|
||||
| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 |
|
||||
| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 |
|
||||
| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 |
|
||||
| `tjwater-cli data scada get\|list --kind info` | `GET /getscadainfo/`、`GET /getallscadainfo/` | `SCADA info` 元数据 |
|
||||
| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 |
|
||||
|
||||
- `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。
|
||||
- `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
POST /realtime/*/batch
|
||||
DELETE /realtime/*
|
||||
PATCH /realtime/*
|
||||
POST /realtime/simulation/store
|
||||
POST /scheme/*/batch
|
||||
PATCH /scheme/*
|
||||
DELETE /scheme/*
|
||||
POST /scheme/simulation/store
|
||||
POST /scada/batch
|
||||
PATCH /scada/{device_id}/field
|
||||
DELETE /scada/by-id-time-range
|
||||
POST /composite/clean-scada
|
||||
POST /setscadadevice/
|
||||
POST /addscadadevice/
|
||||
POST /deletescadadevice/
|
||||
POST /cleanscadadevice/
|
||||
POST /setscadadevicedata/
|
||||
POST /addscadadevicedata/
|
||||
POST /deletescadadevicedata/
|
||||
POST /cleanscadadevicedata/
|
||||
POST /setscadaelement/
|
||||
POST /addscadaelement/
|
||||
POST /deletescadaelement/
|
||||
POST /cleanscadaelement/
|
||||
POST /setextensiondata/
|
||||
POST /test_dict/
|
||||
GET /getjson/
|
||||
```
|
||||
|
||||
### 不纳入首批 CLI 的运维接口
|
||||
|
||||
来源:
|
||||
|
||||
```text
|
||||
app/api/v1/endpoints/snapshots.py
|
||||
app/api/v1/endpoints/cache.py
|
||||
app/api/v1/endpoints/audit.py
|
||||
```
|
||||
|
||||
这些接口不纳入首批 Agent CLI。原因是它们更偏运维、审计或状态回滚,不属于 Agent 面向水务业务分析的核心调用范围。
|
||||
|
||||
暂不暴露:
|
||||
|
||||
```text
|
||||
GET /getcurrentoperationid/
|
||||
GET /getsnapshots/
|
||||
GET /havesnapshot/
|
||||
GET /havesnapshotforoperation/
|
||||
GET /havesnapshotforcurrentoperation/
|
||||
GET /getrestoreoperation/
|
||||
POST /undo/
|
||||
POST /redo/
|
||||
POST /takesnapshot*/
|
||||
POST /picksnapshot/
|
||||
POST /pickoperation/
|
||||
GET /syncwithserver/
|
||||
POST /batch/
|
||||
POST /compressedbatch/
|
||||
POST /setrestoreoperation/
|
||||
GET /queryredis/
|
||||
POST /clearrediskey/
|
||||
POST /clearrediskeys/
|
||||
POST /clearallredis/
|
||||
GET /audit/logs
|
||||
GET /audit/logs/my
|
||||
GET /audit/logs/count
|
||||
```
|
||||
|
||||
## Help
|
||||
|
||||
`help` 不直接对应现有 endpoint,但建议作为 Agent CLI 的基础设施。能力发现更适合复用 CLI 的 `help` 语义,而不是新增一个偏内部化的 `capability` 顶层命令。
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 |
|
||||
| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 |
|
||||
|
||||
输出补充约束:
|
||||
|
||||
- 首批 CLI 不再设计通用 `result_ref` / `--out-ref` 机制。
|
||||
- 若某业务命令确实需要落本地文件,应由所属命令显式提供 `--output PATH`。
|
||||
- 若后续出现超大结果集、必须脱离 stdout 传输时,再单独设计结果引用机制,而不是在首批 CLI 中预埋未闭环能力。
|
||||
|
||||
## 输出规范
|
||||
|
||||
进程级契约:
|
||||
|
||||
- `stdout`:默认只输出一个 JSON 对象,供 agent / 脚本稳定解析。
|
||||
- `stderr`:输出进度、警告和诊断信息;不得混入结构化结果 JSON。
|
||||
- 退出码必须稳定,不能简单透传底层 HTTP status。
|
||||
|
||||
建议退出码:
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|---|---|
|
||||
| `0` | 成功 |
|
||||
| `2` | CLI 参数错误 / 用法错误 |
|
||||
| `3` | 认证失败 |
|
||||
| `4` | 权限不足 |
|
||||
| `5` | 资源不存在 |
|
||||
| `6` | 冲突、前置条件不满足或非法状态 |
|
||||
| `7` | 服务端错误 |
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"schema_version": "tjwater-cli/v1",
|
||||
"summary": "读取成功",
|
||||
"data": {},
|
||||
"metadata": {},
|
||||
"next_commands": []
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"schema_version": "tjwater-cli/v1",
|
||||
"summary": "认证失败",
|
||||
"error": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "missing access token for agent context",
|
||||
"retryable": false
|
||||
},
|
||||
"data": null,
|
||||
"metadata": {},
|
||||
"next_commands": [
|
||||
"tjwater-cli <command> --auth-context /path/to/auth-context.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
补充约束:
|
||||
|
||||
- `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。
|
||||
- `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。
|
||||
- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。
|
||||
|
||||
## 后续开放条件
|
||||
|
||||
如后续要开放写操作,需要单独设计:
|
||||
|
||||
- 权限校验
|
||||
- dry-run / preview
|
||||
- 显式确认机制
|
||||
- 审计日志
|
||||
- 变更快照
|
||||
- 回滚策略
|
||||
- Agent 可读的错误恢复建议
|
||||
Reference in New Issue
Block a user