移除 --auth-context,改为 --auth-stdin,结构化传递解析认证信息

This commit is contained in:
2026-06-02 17:17:00 +08:00
parent 40e699e173
commit c16e6e3d0c
7 changed files with 76 additions and 105 deletions
+38 -48
View File
@@ -28,14 +28,15 @@ class DummyResponse:
return self._json_data
def test_load_auth_context_supports_aliases(tmp_path: Path):
auth_path = tmp_path / "auth.json"
auth_path.write_text(
'{"base_url":"http://server","token":"abc","projectId":"p1","userId":"u1","username":"tester","projectCode":"net1"}',
encoding="utf-8",
)
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")
monkeypatch.setenv("TJWATER_USER_ID", "u1")
monkeypatch.setenv("TJWATER_USERNAME", "tester")
monkeypatch.setenv("TJWATER_NETWORK", "net1")
auth = core.load_auth_context(auth_path)
auth = core.load_auth_context(auth_stdin=False)
assert auth.server == "http://server"
assert auth.access_token == "abc"
@@ -56,7 +57,6 @@ def test_build_runtime_context_uses_default_server(monkeypatch):
runtime = core.build_runtime_context(
server=None,
auth_context_path=None,
scheme=None,
timeout=core.DEFAULT_TIMEOUT,
request_id="req-1",
@@ -93,7 +93,8 @@ def test_simulation_help_lists_subcommands():
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 commands["simulation run"]["example"] == "tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"
assert "tjwater-cli" in commands["simulation run"]["example"]
assert "simulation run" in commands["simulation run"]["example"]
def test_nested_group_help_lists_examples():
@@ -104,7 +105,7 @@ def test_nested_group_help_lists_examples():
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 --auth-context auth.json analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00"
assert commands["analysis leakage identify"]["example"] == "tjwater-cli analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00"
def test_analysis_help_uses_group_summaries_for_nested_groups():
@@ -117,8 +118,8 @@ def test_analysis_help_uses_group_summaries_for_nested_groups():
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 --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01"
assert commands["analysis valve"]["example"] == "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900"
assert commands["analysis burst"]["example"] == "tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --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 --duration 900"
def test_bare_analysis_uses_typer_help_with_descriptions():
@@ -127,7 +128,7 @@ def test_bare_analysis_uses_typer_help_with_descriptions():
assert result.exit_code == 2
assert "分析计算与诊断相关命令。" in result.stdout
assert "burst 执行爆管分析" in result.stdout
assert "valve 执行阀门关闭或隔离分析" 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
@@ -141,7 +142,8 @@ def test_leaf_help_outputs_json():
assert payload["command"] == "simulation run"
assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询"
assert payload["usage"] == "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"
assert payload["examples"] == ["tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"]
assert len(payload["examples"]) == 1
assert "simulation run" in payload["examples"][0]
def test_project_help_uses_legal_kind_example():
@@ -150,8 +152,7 @@ def test_project_help_uses_legal_kind_example():
commands = {command["command"]: command for command in payload["commands"]}
assert result.exit_code == 0
assert commands["project data"]["example"] == "tjwater-cli --auth-context auth.json project data --kind scada-info"
assert "--kind time" not in commands["project data"]["example"]
assert "project data" in commands["project data"]["example"]
def test_root_help_flag_uses_typer_style_with_examples():
@@ -178,11 +179,9 @@ def test_leaf_help_flag_includes_usage_and_example():
def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path):
auth_path = tmp_path / "auth.json"
auth_path.write_text(
'{"server":"http://server","access_token":"abc","network":"demo"}',
encoding="utf-8",
)
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
burst_path = tmp_path / "burst.json"
burst_path.write_text('[{"id":"P1","size":3.5}]', encoding="utf-8")
@@ -194,8 +193,6 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path:
result = runner.invoke(
app,
[
"--auth-context",
str(auth_path),
"analysis",
"burst",
"--start-time",
@@ -211,8 +208,8 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path:
assert result.exit_code == 0
assert '"summary": "爆管分析执行成功"' in result.stdout
assert '"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout
assert '"tjwater-cli --auth-context auth.json data scheme list"' 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_main_missing_option_error_includes_usage_and_next_step(capsys):
@@ -236,12 +233,11 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys):
assert '"ok": false' not in stdout
def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path):
auth_path = tmp_path / "auth.json"
auth_path.write_text(
'{"server":"http://server","access_token":"abc","project_id":"pid","network":"demo"}',
encoding="utf-8",
)
def test_project_list_uses_auth_stdin(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_PROJECT_ID", "pid")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
@@ -250,7 +246,7 @@ def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path):
monkeypatch.setattr(core.requests, "request", fake_request)
result = runner.invoke(app, ["--auth-context", str(auth_path), "project", "list"])
result = runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
assert '"ok": true' in result.stdout
@@ -258,12 +254,10 @@ def test_project_list_uses_auth_headers(monkeypatch, tmp_path: Path):
assert captured["url"] == "http://server/api/v1/meta/projects"
def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path):
auth_path = tmp_path / "auth.json"
auth_path.write_text(
'{"server":"http://server","access_token":"abc","network":"demo"}',
encoding="utf-8",
)
def test_simulation_run_translates_rfc3339(monkeypatch):
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
captured = {}
def fake_request(**kwargs):
@@ -275,8 +269,6 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path):
result = runner.invoke(
app,
[
"--auth-context",
str(auth_path),
"simulation",
"run",
"--start-time",
@@ -293,16 +285,14 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path):
"start_time": "03:04:05+08:00",
"duration": 30,
}
assert '"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout
assert '"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout
assert "tjwater-cli data timeseries realtime links" in result.stdout
assert "tjwater-cli data timeseries realtime nodes" in result.stdout
def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path):
auth_path = tmp_path / "auth.json"
auth_path.write_text(
'{"server":"http://server","access_token":"abc","network":"demo"}',
encoding="utf-8",
)
monkeypatch.setenv("TJWATER_SERVER", "http://server")
monkeypatch.setenv("TJWATER_ACCESS_TOKEN", "abc")
monkeypatch.setenv("TJWATER_NETWORK", "demo")
output = tmp_path / "demo.inp"
calls = []
@@ -320,7 +310,7 @@ def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path):
result = runner.invoke(
app,
["--auth-context", str(auth_path), "project", "export-inp", "--output", str(output)],
["project", "export-inp", "--output", str(output)],
)
assert result.exit_code == 0
+4 -4
View File
@@ -58,8 +58,8 @@ def simulation_run(
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}",
f"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}",
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}",
],
)
@@ -92,8 +92,8 @@ def analysis_burst(
require_auth=True,
require_network_ctx=True,
next_commands=[
f"tjwater-cli --auth-context auth.json data scheme get --name {scheme_name}",
"tjwater-cli --auth-context auth.json data scheme list",
f"tjwater-cli data scheme get --name {scheme_name}",
"tjwater-cli data scheme list",
],
)
+1 -2
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import typer
@@ -12,7 +11,7 @@ def runtime_context(ctx: typer.Context):
obj = ctx.obj or {}
return build_runtime_context(
server=obj.get("server"),
auth_context_path=obj.get("auth_context"),
auth_stdin=obj.get("auth_stdin", False),
scheme=obj.get("scheme"),
timeout=obj.get("timeout", DEFAULT_TIMEOUT),
request_id=obj.get("request_id"),
+10 -29
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import sys
import time
import uuid
from dataclasses import dataclass, field
@@ -80,25 +81,6 @@ class CommandDoc:
output: str = "标准 JSON 输出"
def _read_json_file(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise CLIError(
"认证失败",
code="AUTH_CONTEXT_NOT_FOUND",
message=f"auth context file not found: {path}",
exit_code=3,
) from exc
except json.JSONDecodeError as exc:
raise CLIError(
"认证失败",
code="AUTH_CONTEXT_INVALID",
message=f"auth context file is not valid JSON: {path}",
exit_code=3,
) from exc
def _pick(mapping: Mapping[str, Any], *keys: str) -> Any:
for key in keys:
value = mapping.get(key)
@@ -107,10 +89,9 @@ def _pick(mapping: Mapping[str, Any], *keys: str) -> Any:
return None
def load_auth_context(auth_context_path: Path | None) -> AuthContext:
raw: dict[str, Any] = {}
if auth_context_path is not None:
raw = _read_json_file(auth_context_path)
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 = {
@@ -146,12 +127,12 @@ def load_auth_context(auth_context_path: Path | None) -> AuthContext:
def build_runtime_context(
*,
server: str | None,
auth_context_path: Path | None,
auth_stdin: bool = False,
scheme: str | None,
timeout: int,
request_id: str | None,
) -> RuntimeContext:
auth = load_auth_context(auth_context_path)
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,
@@ -181,7 +162,7 @@ def require_access_token(ctx: RuntimeContext) -> str:
code="UNAUTHENTICATED",
message="missing access token for agent context",
exit_code=3,
next_commands=["tjwater-cli <command> --auth-context /path/to/auth-context.json"],
next_commands=["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"],
)
@@ -193,7 +174,7 @@ def require_project_id(ctx: RuntimeContext) -> str:
code="PROJECT_CONTEXT_REQUIRED",
message="missing project_id for agent context",
exit_code=3,
next_commands=["add project_id to the auth context file"],
next_commands=["add project_id to auth context"],
)
@@ -205,7 +186,7 @@ def require_network(ctx: RuntimeContext) -> str:
code="NETWORK_CONTEXT_REQUIRED",
message="missing network in auth context for legacy network-based endpoints",
exit_code=3,
next_commands=["add network to the auth context file"],
next_commands=["add network to auth context"],
)
@@ -217,7 +198,7 @@ def require_username(ctx: RuntimeContext) -> str:
code="USERNAME_CONTEXT_REQUIRED",
message="missing username in auth context",
exit_code=3,
next_commands=["add username to the auth context file"],
next_commands=["add username to auth context"],
)
+2 -3
View File
@@ -161,11 +161,10 @@ def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None
]
if existing_examples:
for example in existing_examples:
has_auth = "--auth-context" in example
has_required_options = all(f"--{option_name}" in example for option_name in required_option_names)
if has_auth and has_required_options:
if has_required_options:
return example
parts = ["tjwater-cli", "--auth-context", "auth.json", *path]
parts = ["tjwater-cli", *path]
if ctx is None:
return " ".join(parts)
for parameter in ctx.command.params:
+2 -2
View File
@@ -27,14 +27,14 @@ from .helping import (
def root_callback(
ctx: typer.Context,
server: Annotated[str | None, typer.Option("--server", help=f"服务端地址,默认 {DEFAULT_SERVER}")] = None,
auth_context: Annotated[Path | None, typer.Option("--auth-context", help="认证上下文 JSON 文件")] = None,
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_context": auth_context,
"auth_stdin": auth_stdin,
"scheme": scheme,
"timeout": timeout,
"request_id": request_id,
+19 -17
View File
@@ -39,15 +39,14 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
path=("project", "list"),
summary="列出当前用户可访问项目",
description="调用 /meta/projects 返回项目列表。",
examples=("tjwater-cli --auth-context auth.json project list",),
next_commands=("tjwater-cli --auth-context auth.json project info",),
output="项目摘要列表",
examples=("tjwater-cli project list",),
next_commands=("tjwater-cli project info",),
),
("project", "info"): CommandDoc(
path=("project", "info"),
summary="读取当前项目元数据",
description="调用 /meta/project 返回当前 project 详情",
examples=("tjwater-cli --auth-context auth.json project info",),
summary="查看当前项目摘要信息。",
description="查看当前项目的基础信息",
examples=("tjwater-cli project info",),
output="项目元数据",
),
("project", "db-health"): CommandDoc(
@@ -109,8 +108,8 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
CommandOptionDoc("duration", "持续分钟数", required=True),
),
next_commands=(
"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00",
"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 命令按时间段查询",
),
@@ -125,20 +124,23 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = {
CommandOptionDoc("scheme", "方案名称"),
),
examples=(
"tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01",
"tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01",
"tjwater-cli data scheme get --name burst_case_01",
"tjwater-cli data scheme list",
),
next_commands=(
"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01",
"tjwater-cli --auth-context auth.json data scheme list",
),
output="分析执行结果;方案详情需通过 data scheme 命令单独查询",
),
("analysis", "valve"): CommandDoc(
path=("analysis", "valve"),
summary="执行阀门关闭或隔离分析",
description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve",
summary="阀门工况分析",
description="指定阀门采取关闭/开启等操作逻辑,并执行定时长模拟。结果写入时序库",
options=(
CommandOptionDoc(name="mode", description="阀门操作模式:'close''open'", required=True),
CommandOptionDoc(name="start-time", description="起始绝对时间,必须显式带时区偏移", required=True),
CommandOptionDoc(name="valve", description="阀门 ID(可多次指定)", required=True, repeated=True),
CommandOptionDoc(name="duration", description="模拟持续分钟数", required=True),
),
examples=(
"tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900",
"tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900",
),
),
("analysis", "flushing"): CommandDoc(