From c16e6e3d0c5c9a90e19fdede2434fef6576fffd0 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 17:17:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20--auth-context=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20--auth-stdin=EF=BC=8C=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E4=BC=A0=E9=80=92=E8=A7=A3=E6=9E=90=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/tests/unit/test_tjwater_cli.py | 86 ++++++++++++---------------- cli/tjwater_cli/commands_analysis.py | 8 +-- cli/tjwater_cli/common.py | 3 +- cli/tjwater_cli/core.py | 39 ++++--------- cli/tjwater_cli/helping.py | 5 +- cli/tjwater_cli/main.py | 4 +- cli/tjwater_cli/registry.py | 36 ++++++------ 7 files changed, 76 insertions(+), 105 deletions(-) diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 5748cc7..dce51f4 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -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 --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 --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 diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 3708caa..47164fa 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -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", ], ) diff --git a/cli/tjwater_cli/common.py b/cli/tjwater_cli/common.py index b03624c..a112150 100644 --- a/cli/tjwater_cli/common.py +++ b/cli/tjwater_cli/common.py @@ -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"), diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index cf1187e..34eaeb4 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -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 --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"], ) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 5f01547..1c35acd 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -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: diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index 9cddbdb..7503b31 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -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, diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index 8b964f5..6dd5f46 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -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(