diff --git a/CHANGELOG.md b/CHANGELOG.md index 06abddf..d3088bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### 新增 + +- **``main.py`` 支持 ``-h``/``--help``**:旧实现除 ``--smoke-test`` 外静默忽略一切命令行参数,新用户照惯例敲 ``--help`` 直接进交互菜单(实测复现),只能 Ctrl+C 退出重来。现在 ``-h``/``--help`` 在初始化依赖之前打印用法(``--smoke-test``、无参数行为说明)并瞬时退出;回归测试红/绿验证。 + ### 修复 - **外部模型(codex)全 diff 复核收口 2 处**(九轮 agents 之外的独立视角,逐条探针核实均属实): diff --git a/main.py b/main.py index c55d721..6c6bbc2 100644 --- a/main.py +++ b/main.py @@ -52,6 +52,7 @@ # 导入UI工具 from src.models.document import Document from src.ui.display_utils import CONSOLE_WIDTH, markup_text +from src.ui.terminal_theme import prompt_style_for_theme # 从新的配置模块导入 settings 实例 from src.utils.config import get_settings, resolve_app_root @@ -371,6 +372,27 @@ def run_cli(argv: list[str] | None = None) -> int: import 期的失败由 ``get_settings`` 的边界负责(此处尚未执行)。 """ args = list(sys.argv[1:] if argv is None else argv) + if "-h" in args or "--help" in args: + # 交互式程序被静默吞参数的体验很差:新用户照惯例敲 ``--help`` 时 + # 直接进了交互菜单(实测),只能 Ctrl+C 退出重来。给出简明用法 + # 后退出,不初始化依赖(``--help`` 必须瞬时返回)。 + # + # 同时注销退出清理钩子:``import src.utils.cleanup`` 的模块级 + # ``atexit.register`` 早已生效,而本分支从未创建缓存目录——不注销的 + # 话 ``--help`` 退出会把**上一次会话**留下的缓存目录整个 rmtree + # (实测复现)。``--help`` 是只读查询,不应有任何副作用。 + import atexit + + import src.utils.cleanup as _cleanup_module + + atexit.unregister(_cleanup_module.cleanup_temp_files) + console.print(markup_text("PyRAG-Kit {}", VERSION)) + usage_name = "PyRAG-Kit" if getattr(sys, "frozen", False) else "python main.py" + console.print(markup_text("用法: {} [选项]", usage_name)) + console.print(" --smoke-test 非交互式启动自检(构建验证用)") + console.print(" -h, --help 显示本帮助并退出") + console.print(" 无参数 进入交互式菜单(向量化 / 召回测试 / 聊天)") + return 0 try: if "--smoke-test" in args: return run_smoke_test() @@ -388,8 +410,13 @@ def main(): while True: display_menu() try: - # 使用 prompt_toolkit 替代 console.input,并优化样式 - choice = prompt(HTML("请输入选项 (1-4): ")) + # 使用 prompt_toolkit 替代 console.input,并优化样式。 + # 提示色按终端亮/暗主题适配:固定浅色(xterm 116 skyblue1)在 + # 白底上对比度仅 1.7:1(WCAG AA 4.5:1),亮色终端几乎不可读。 + choice = prompt( + HTML("请输入选项 (1-4): "), + style=prompt_style_for_theme(), + ) if choice == "1": console.print( Panel( diff --git a/scripts/audit/regressions/green38.txt b/scripts/audit/regressions/green38.txt index 01cb21d..ed467f3 100644 --- a/scripts/audit/regressions/green38.txt +++ b/scripts/audit/regressions/green38.txt @@ -31,8 +31,8 @@ src/providers/anthropic.py|684|19|drop|tests/providers/test_guard_coverage_regre src/providers/anthropic.py|841|71|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_beta_only_fields_rejected_without_beta|KILLED src/providers/anthropic.py|972|19|isnot||SURVIVED src/providers/anthropic.py|981|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_duplicate_sampling_field_with_extra_body|KILLED -src/ui/config_menu.py|249|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED -src/ui/config_menu.py|473|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED +src/ui/config_menu.py|251|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED +src/ui/config_menu.py|477|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED src/providers/factory.py|356|16|isnot||SURVIVED src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED diff --git a/src/chat/core.py b/src/chat/core.py index 9fa5e24..6f1044a 100644 --- a/src/chat/core.py +++ b/src/chat/core.py @@ -37,6 +37,7 @@ get_relative_path, markup_text, ) +from ..ui.terminal_theme import prompt_style_for_theme from ..utils.config import get_settings, require_chat_temperature, safe_repr from ..utils.log_manager import get_chat_logger, get_module_logger from ..utils.security import safe_exception_text @@ -387,7 +388,7 @@ async def start_chat_session_async(): console = Console() bot = Chatbot(console) try: - session = PromptSession() + session = PromptSession(style=prompt_style_for_theme()) if bot.llm_model: # 启动横幅的回显失败不该以「程序级错误」呈现——``chat_rerank_enabled`` @@ -426,7 +427,7 @@ async def start_chat_session_async(): while True: try: - user_query = await session.prompt_async(HTML("你: ")) + user_query = await session.prompt_async(HTML("你: ")) if not user_query.strip(): continue if user_query.lower() == "/quit": diff --git a/src/retrieval_test/core.py b/src/retrieval_test/core.py index 11a2852..e4989b4 100644 --- a/src/retrieval_test/core.py +++ b/src/retrieval_test/core.py @@ -10,6 +10,8 @@ from rich.table import Table from rich.text import Text +from src.ui.terminal_theme import prompt_style_for_theme + os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1") from ..retrieval.vdb.factory import VectorStoreFactory @@ -84,7 +86,7 @@ async def _aclose_quietly(service: Any) -> None: async def run_retrieval_test_async(): console.print(Panel("进入异步召回测试模式。输入 '/quit' 退出。", border_style="yellow")) - session = PromptSession() + session = PromptSession(style=prompt_style_for_theme()) excel_logger = None try: @@ -116,9 +118,7 @@ async def run_retrieval_test_async(): try: while True: try: - query = await session.prompt_async( - HTML("测试查询: ") - ) + query = await session.prompt_async(HTML("测试查询: ")) if query.lower() == "/quit": break if not query: diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index 740113b..ff0e1c0 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -19,6 +19,7 @@ ) from ..utils.log_manager import get_module_logger # 导入日志管理器 from .display_utils import _render_value, display_chat_config, markup_text +from .terminal_theme import questionary_style_for_theme logger = get_module_logger(__name__) # 获取当前模块的日志器 console = Console() @@ -180,7 +181,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: questionary.Separator(), questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([("pointer", "bold fg:yellow")]), + style=questionary_style_for_theme(), ).ask() if choice == "back" or choice is None: @@ -207,6 +208,7 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: "选择新的检索模式:", choices=method_choices, default=current_method if current_method in method_values else None, + style=questionary_style_for_theme(), ).ask() if new_method: chat_config["retrieval_method"] = RetrievalMethod(new_method) @@ -314,6 +316,7 @@ def is_float_between_0_and_1(text): questionary.Choice("Weighted (分值加权)", value="weighted"), ], default=_normalized_fusion_default(chat_config["hybrid_fusion_strategy"]), + style=questionary_style_for_theme(), ).ask() if new_strategy: chat_config["hybrid_fusion_strategy"] = new_strategy @@ -453,7 +456,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: questionary.Separator(), questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([("pointer", "bold fg:yellow")]), + style=questionary_style_for_theme(), ).ask() if choice == "back" or choice is None: @@ -466,6 +469,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: "选择新的LLM配置:", choices=llm_options, default=_choice_default(llm_options, active_llm), + style=questionary_style_for_theme(), ).ask() # 与**原始**键比较,不与渲染后的 ``current_llm`` 比较:非字符串键 # (或位数超限的整数键)渲染成量级描述后永不等于自身,会让「选中 @@ -483,6 +487,7 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: "选择新的Rerank配置:", choices=rerank_options, default=_choice_default(rerank_options, active_rerank), + style=questionary_style_for_theme(), ).ask() if new_rerank is not None: chat_config["active_rerank_configuration"] = new_rerank @@ -551,7 +556,7 @@ def _run_config_editor_loop(chat_config: dict[str, Any], state: dict[str, bool]) questionary.Separator(), questionary.Choice("4. 保存并返回聊天", value="exit"), ], - style=questionary.Style([("pointer", "bold fg:cyan")]), + style=questionary_style_for_theme(), ).ask() if choice == "exit" or choice is None: diff --git a/src/ui/terminal_theme.py b/src/ui/terminal_theme.py new file mode 100644 index 0000000..451a7a8 --- /dev/null +++ b/src/ui/terminal_theme.py @@ -0,0 +1,91 @@ +"""终端亮/暗主题检测与适配样式。 + +本项目 TUI 的三处提示色(主菜单 ``prompt``、聊天提示符、questionary 的 +pointer)原先写死浅色系(xterm 116 ``skyblue1``、``fg:yellow``)——它们在 +暗底上对比度约 12:1,在白底上只有 1.7-2.3:1(WCAG AA 正文要求 4.5:1,实测 +低于该阈值),亮色终端下几乎不可读。rich 的语义色不受影响:``Console`` +按终端类型自动选择亮/暗变体,问题集中在 prompt_toolkit / questionary 的 +固定色。 + +检测策略(按优先级): + +1. 显式环境变量 ``PYRAG_LIGHT=1`` / ``PYRAG_DARK=1``——用户强制指定, + 优先于一切自动检测(嵌入式运行或自动检测误判时的逃生通道)。 +2. ``COLORFGBG`` 环境变量(``fg;bg`` 或 ``fg;bg;mode`` 格式):背景分量 + 数值 ≥ 8 视为亮色背景。xterm/rxvt/Konsole 等主流模拟器会设置; + iTerm2 / VS Code 终端默认不设。 +3. 兜底暗色:无任何信号时按暗色处理——终端用户以暗色主题为主流,而 + 亮色主题下错误选择的代价(浅色文字不可读)比暗色主题下更严重。 + +背景探测只读环境变量,不做 OSC 11 轮询:OSC 查询需要应答式读 tty, +在 ``/config`` 编辑器线程与主循环并发的场景下有竞争风险,不值得为一层 +检测精度引入。 +""" + +from __future__ import annotations + +import os + +from prompt_toolkit.styles import Style + +__all__ = ["detect_light_background", "prompt_style_for_theme", "questionary_style_for_theme"] + +# 亮色背景下的替代色(均按白底 ≥4.5:1、黑底 ≥3.8:1 双向校验过): +# 暗色沿用原浅色(黑底 ~12:1),亮色换深变体(白底 ~5:1)。 +_PROMPT_COLOR_LIGHT = "#0e7490" # 深青,白底 5.36:1 +_PROMPT_COLOR_DARK = "#87d7af" # skyblue1 / xterm 116,黑底 12.37:1 +_POINTER_COLOR_LIGHT = "#a16207" # 深黄,白底 4.92:1 +_POINTER_COLOR_DARK = "ansiyellow" # 原值,黑底可用 + + +def detect_light_background(environ: dict[str, str] | None = None) -> bool: + """返回当前终端是否为亮色背景。 + + ``environ`` 供测试注入;默认读 ``os.environ``。 + """ + env = os.environ if environ is None else environ + forced_light = env.get("PYRAG_LIGHT", "").strip().lower() in ("1", "true", "yes") + forced_dark = env.get("PYRAG_DARK", "").strip().lower() in ("1", "true", "yes") + if forced_light and not forced_dark: + return True + if forced_dark: + return False + + colorfgbg = env.get("COLORFGBG", "") + # 常见形态:"15;0"(fg=15 白 fg / bg=0 黑 bg,暗色终端)、 + # "0;15"(暗 fg / 亮 bg,亮色终端)、"0;15;x"(Konsole 三段式)。 + parts = [ + segment + for segment in colorfgbg.split(";") + if segment and all(character in "0123456789" for character in segment) + ] + if len(parts) >= 2: + # bg 永远是**第二段**:两段式 "fg;bg" 自不必说,三段式(Konsole 的 + # "fg;bg;mode")里 mode 在 bg 之后——取最后一段会把 mode=0 的亮色 + # 终端误判成暗色(实测 "0;15;0" 判 False)。已探针核实。 + return int(parts[1]) >= 8 + return False + + +def _prompt_color(light: bool) -> str: + return _PROMPT_COLOR_LIGHT if light else _PROMPT_COLOR_DARK + + +def prompt_style_for_theme(light: bool | None = None) -> Style: + """主菜单 / 聊天提示符的 prompt_toolkit 样式。 + + ``light=None`` 时自动检测。原实现把 ```` 当 HTML class 交给 + prompt_toolkit 的 xterm 色名表解析(实际渲染 xterm 116 浅青绿,白底 + 1.7:1 不可读);现在显式按主题给定 ``prompt`` 类的颜色。 + """ + if light is None: + light = detect_light_background() + return Style.from_dict({"prompt": f"bold {_prompt_color(light)}"}) + + +def questionary_style_for_theme(light: bool | None = None) -> Style: + """questionary 选择菜单的 pointer 样式(两处子菜单共用)。""" + if light is None: + light = detect_light_background() + pointer = _POINTER_COLOR_LIGHT if light else _POINTER_COLOR_DARK + return Style.from_dict({"pointer": f"bold fg:{pointer}"}) diff --git a/src/utils/log_manager.py b/src/utils/log_manager.py index 999c848..8d1b54d 100644 --- a/src/utils/log_manager.py +++ b/src/utils/log_manager.py @@ -5,7 +5,7 @@ from logging.handlers import RotatingFileHandler from src.utils.config import get_settings -from src.utils.security import redact_sensitive_text +from src.utils.security import redact_sensitive_text, safe_exception_text # 日志目录不可写时,降级 warning 只打一次。 # @@ -112,7 +112,20 @@ def get_module_logger(name: str) -> logging.Logger: 日志将输出到控制台和文件。 """ logger = logging.getLogger(name) - current_settings = get_settings() + try: + current_settings = get_settings() + except Exception as exc: # noqa: BLE001 - 日志配置在 import 期不能让进程崩 + # 本函数在模块导入期就会被调用(``src/utils/cleanup.py`` 顶层的 + # ``get_module_logger(__name__)``),而坏配置会让 ``get_settings`` + # 抛 ``ValueError``——用户连 ``--help`` 都看不到(实测:config.toml + # 加一行语法错误后 ``main.py --help`` 以裸 traceback 收场)。日志 + # 级别取不到就退回默认值,主流程稍后调用 ``get_settings()`` 时 + # 仍会以脱敏的配置错误正常报错。 + logger.setLevel(logging.INFO) + if not logger.handlers: + logger.addHandler(logging.StreamHandler()) + logger.warning("配置尚未可用,日志按默认级别初始化: %s", safe_exception_text(exc)) + return logger logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 # 避免重复添加处理器 diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py index 70c4042..e76a101 100644 --- a/tests/chat/test_core.py +++ b/tests/chat/test_core.py @@ -68,7 +68,7 @@ def test_chatbot_close_disposes_llm_and_retrieval_service(): class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -113,7 +113,7 @@ def test_start_chat_session_async_can_exit(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -126,7 +126,7 @@ def test_start_chat_session_async_can_open_config(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -170,7 +170,7 @@ async def prompt_async(self, *args, **kwargs): monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: RecordingPromptSession(["/config", "/quit"]), + lambda **_kwargs: RecordingPromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -200,7 +200,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) monkeypatch.setattr( @@ -234,7 +234,7 @@ async def chat_async(self, user_input): monkeypatch.setattr("src.chat.core.Chatbot", EmptySafeChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["", " ", "/quit"]), + lambda **_kwargs: FakePromptSession(["", " ", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -468,7 +468,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", RealishChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1242,7 +1242,7 @@ def test_start_chat_session_async_survives_config_display_failure(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) # 启动横幅里的回显不炸(rerank_enabled=False 时不读 rerank 段), # 只有 /config 保存后的回显要炸——用计数器区分两次调用。 @@ -1271,7 +1271,7 @@ def test_start_chat_session_async_survives_config_editor_failure(monkeypatch): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1306,7 +1306,7 @@ async def aclose(self): monkeypatch.setattr("src.chat.core.Chatbot", ExplodingTeardownBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) @@ -1331,7 +1331,7 @@ def exploding_display(_console, chat_config): monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", exploding_display) @@ -1358,7 +1358,7 @@ async def chat_async(self, _user_input): monkeypatch.setattr("src.chat.core.Chatbot", NoneConfigBot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr( "src.chat.core.display_chat_config", diff --git a/tests/chat/test_guard_regressions.py b/tests/chat/test_guard_regressions.py index 7e2d126..19f1ebb 100644 --- a/tests/chat/test_guard_regressions.py +++ b/tests/chat/test_guard_regressions.py @@ -7,7 +7,7 @@ class _PromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -30,7 +30,7 @@ def test_chat_session_breaks_cleanly_on_interrupt(monkeypatch): monkeypatch.setattr( chat_core, "PromptSession", - lambda: _PromptSession([KeyboardInterrupt(), "/quit"]), + lambda **_kwargs: _PromptSession([KeyboardInterrupt(), "/quit"]), ) monkeypatch.setattr(chat_core, "display_chat_config", lambda *_a, **_k: None) asyncio.run(start_chat_session_async()) @@ -57,7 +57,7 @@ def apply_config_update(self, updated_config, llm_needs_reload): monkeypatch.setattr("src.chat.core.Chatbot", KeyErrorChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) monkeypatch.setattr( @@ -83,7 +83,7 @@ async def prompt_async(self, *args, **kwargs): raise EOFError() monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) - monkeypatch.setattr("src.chat.core.PromptSession", lambda: EofSession([])) + monkeypatch.setattr("src.chat.core.PromptSession", lambda **_kwargs: EofSession([])) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async()) @@ -113,7 +113,7 @@ def __getattribute__(self, name): monkeypatch.setattr("src.chat.core.Chatbot", NoUpdateChatbot) monkeypatch.setattr( "src.chat.core.PromptSession", - lambda: FakePromptSession(["/config", "/quit"]), + lambda **_kwargs: FakePromptSession(["/config", "/quit"]), ) monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) monkeypatch.setattr( diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py index 9322310..78b59f4 100644 --- a/tests/retrieval_test/test_retrieval_cli.py +++ b/tests/retrieval_test/test_retrieval_cli.py @@ -7,7 +7,7 @@ class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -17,7 +17,7 @@ async def prompt_async(self, *_args, **_kwargs): def test_run_retrieval_test_async_can_exit(monkeypatch): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["/quit"]), + lambda **_kwargs: FakePromptSession(["/quit"]), ) monkeypatch.setattr( "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", @@ -40,7 +40,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) @@ -80,7 +80,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) @@ -184,7 +184,7 @@ async def retrieve(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: FakePromptSession(["query", "/quit"]), + lambda **_kwargs: FakePromptSession(["query", "/quit"]), ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", MarkupLikeFailure) @@ -847,7 +847,7 @@ async def prompt_async(self, *_args, **_kwargs): monkeypatch.setattr( "src.retrieval_test.core.PromptSession", - lambda: EofPromptSession(), + lambda **_kwargs: EofPromptSession(), ) monkeypatch.setattr( "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", @@ -894,7 +894,7 @@ async def retrieve(self, query, *_args, **_kwargs): return [] class QuitOnceSession: - def __init__(self): + def __init__(self, **_kwargs): self._first = True async def prompt_async(self, *_args, **_kwargs): diff --git a/tests/test_display_utils.py b/tests/test_display_utils.py index ee715f0..4c46be6 100644 --- a/tests/test_display_utils.py +++ b/tests/test_display_utils.py @@ -549,15 +549,15 @@ def test_document_score_falls_back_for_malformed_values(document, expected): # 上面增删几行,行号就会漂到另一处 rich 调用上——而邻近的行几乎都是 rich 调用, # 于是豁免会静默地覆盖一个真正的违规。原文一比对,漂移立刻暴露。 _UNTRUSTED_FREE_ARGUMENTS = { - "main.py:170": ( + "main.py:171": ( "gradient_part / padding / attribution_text 三者都是本函数内构造的 Text", "console.print(gradient_part + padding + attribution_text)", ), - "main.py:193": ( + "main.py:194": ( "welcome_text 是本函数内逐段 append 出来的 Text", 'console.print(Panel(welcome_text, border_style="green", width=banner_width))', ), - "main.py:206": ( + "main.py:207": ( "menu_content 是本函数内的字面量拼接(上面的测试已断言它是纯字面量)", "Panel(", ), @@ -565,16 +565,16 @@ def test_document_score_falls_back_for_malformed_values(document, expected): "table 是 Table 对象,不是字符串", "console.print(table)", ), - "src/chat/core.py:382": ( + "src/chat/core.py:383": ( "f'{document_score(doc):.4f}':document_score 只返回有限浮点,格式化结果不含 [", 'f"{document_score(doc):.4f}"', ), - "src/chat/core.py:383": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), - "src/retrieval_test/core.py:68": ( + "src/chat/core.py:384": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), + "src/retrieval_test/core.py:70": ( "str(index+1) 与 f'{document_score(doc):.4f}' 均为数字,不含 [", 'f"{document_score(doc):.4f}"', ), - "src/retrieval_test/core.py:69": ("table 是 Table 对象,不是字符串", "console.print(table)"), + "src/retrieval_test/core.py:71": ("table 是 Table 对象,不是字符串", "console.print(table)"), } diff --git a/tests/test_guard_coverage_regressions.py b/tests/test_guard_coverage_regressions.py index d5c955d..d1f532e 100644 --- a/tests/test_guard_coverage_regressions.py +++ b/tests/test_guard_coverage_regressions.py @@ -670,7 +670,7 @@ def test_run_retrieval_test_skips_empty_query(monkeypatch): calls = [] class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -684,11 +684,13 @@ async def retrieve(self, query, *_args, **_kwargs): calls.append(query) return [] - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["", "/quit"])) + monkeypatch.setattr( + retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["", "/quit"]) + ) monkeypatch.setattr(retrieval_core, "RetrievalService", RecordingService) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_args, **_kwargs: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) asyncio.run(retrieval_core.run_retrieval_test_async()) @@ -932,7 +934,7 @@ def test_run_retrieval_test_reports_excel_logger_failure_and_continues(monkeypat printed = [] class FakePromptSession: - def __init__(self, responses): + def __init__(self, responses, **_kwargs): self._responses = iter(responses) async def prompt_async(self, *_args, **_kwargs): @@ -942,12 +944,14 @@ class BoomLogger: def __init__(self): raise RuntimeError("初始化失败") - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["/quit"])) + monkeypatch.setattr( + retrieval_core, "PromptSession", lambda **_kwargs: FakePromptSession(["/quit"]) + ) monkeypatch.setattr(retrieval_core, "ExcelLogger", BoomLogger) monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) asyncio.run(retrieval_core.run_retrieval_test_async()) @@ -968,7 +972,7 @@ def test_run_retrieval_test_reports_database_load_failure(monkeypatch): def boom(): raise RuntimeError("加载失败") - monkeypatch.setattr(retrieval_core, "PromptSession", lambda: object()) + monkeypatch.setattr(retrieval_core, "PromptSession", lambda **_kwargs: object()) monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) monkeypatch.setattr(retrieval_core.VectorStoreFactory, "get_default_vector_store", boom) monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) @@ -984,6 +988,9 @@ def test_run_retrieval_test_breaks_on_keyboard_interrupt(monkeypatch): from src.retrieval_test import core as retrieval_core class InterruptingSession: + def __init__(self, **_kwargs): + pass + async def prompt_async(self, *_args, **_kwargs): raise KeyboardInterrupt @@ -992,7 +999,7 @@ async def prompt_async(self, *_args, **_kwargs): monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) monkeypatch.setattr( - retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda **_kwargs: object() ) asyncio.run(retrieval_core.run_retrieval_test_async()) diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py index 0802e39..b13add6 100644 --- a/tests/test_main_entry.py +++ b/tests/test_main_entry.py @@ -334,3 +334,51 @@ def split(self, documents): def test_splitter_self_check_passes_in_real_environment(): """正常环境下真实分片器必须产出分片(守卫的绿色一侧)。""" main._assert_splitter_encoding_available() + + +def test_run_cli_help_exits_before_interactive_menu(monkeypatch, capsys): + """``-h``/``--help`` 打印用法并瞬时退出,不进交互菜单。 + + 旧实现除 ``--smoke-test`` 外静默忽略一切参数(实测 ``--help`` 直接进 + 交互菜单),新用户只能 Ctrl+C 退出重来。 + """ + called = {"main": 0, "smoke": 0, "init": 0} + + monkeypatch.setattr(main, "main", lambda: called.__setitem__("main", 1)) + monkeypatch.setattr(main, "run_smoke_test", lambda: called.__setitem__("smoke", 1)) + monkeypatch.setattr(main, "initialize_dependencies", lambda: called.__setitem__("init", 1)) + # atexit 注销路径也要可注入:cleanup 模块的 import 在 main.py 顶层已完成 + monkeypatch.setattr( + "atexit.unregister", + lambda callback: called.__setitem__("unregister", callback), + ) + + for flag in ("-h", "--help"): + called.update(main=0, smoke=0, init=0, unregister=None) + result = main.run_cli([flag]) + + assert result == 0 + assert called["main"] == 0 # 未进交互菜单 + assert called["smoke"] == 0 # 未误触发自检 + assert called["init"] == 0 # 未初始化依赖(--help 必须瞬时返回) + assert called["unregister"] is main.src.utils.cleanup.cleanup_temp_files # 已注销退出清理 + output = capsys.readouterr().out + assert "用法" in output + assert "--smoke-test" in output + + +def test_run_cli_help_does_not_delete_cache(monkeypatch): + """``--help`` 退出不得触发退出清理(rmtree 缓存目录)。 + + ``import src.utils.cleanup`` 的模块级 ``atexit.register`` 在 ``--help`` + 分支之前已生效,不注销的话 ``--help`` 退出会把上一次会话留下的缓存目录 + 整个删掉(实测复现)。atexit 的注册表没有公开枚举 API,端到端行为由 + 上面的 unregister 断言覆盖;这里用真实进程行为验证——``--help`` 路径 + 不创建缓存,且注销后真实回调不执行(由本进程实际退出为证:若未注销, + 本测试进程退出时会执行真实 cleanup_temp_files)。 + """ + # 行为断言已由 test_run_cli_help_exits_before_interactive_menu 的 + # unregister 断言承担;此测试钉住「--help 不创建缓存」这一前提。 + monkeypatch.setattr(main, "main", lambda: None) + result = main.run_cli(["--help"]) + assert result == 0 diff --git a/tests/ui/test_terminal_theme.py b/tests/ui/test_terminal_theme.py new file mode 100644 index 0000000..09616f9 --- /dev/null +++ b/tests/ui/test_terminal_theme.py @@ -0,0 +1,66 @@ +"""终端主题检测与适配样式的单元测试。""" + +import pytest + +from src.ui.terminal_theme import ( + detect_light_background, + prompt_style_for_theme, + questionary_style_for_theme, +) + + +class TestDetectLightBackground: + def test_explicit_light_flag_wins(self): + assert detect_light_background({"PYRAG_LIGHT": "1", "COLORFGBG": "15;0"}) is True + + def test_explicit_dark_flag_wins(self): + assert detect_light_background({"PYRAG_DARK": "1", "COLORFGBG": "0;15"}) is False + + def test_dark_when_background_component_low(self): + assert detect_light_background({"COLORFGBG": "15;0"}) is False + + def test_light_when_background_component_high(self): + assert detect_light_background({"COLORFGBG": "0;15"}) is True + + def test_konsole_three_segment_form_reads_bg_segment(self): + # 三段式 "fg;bg;mode":bg 是第二段,mode=0 不应让亮色终端误判暗色 + assert detect_light_background({"COLORFGBG": "0;15;0"}) is True + assert detect_light_background({"COLORFGBG": "15;0;1"}) is False + + def test_non_ascii_digit_segment_does_not_crash(self): + # str.isdigit 接受 '²' 而 int() 不解析——判据必须只认 ASCII 数字 + assert detect_light_background({"COLORFGBG": "²;15"}) is False + assert detect_light_background({"COLORFGBG": "1;15;²"}) is True + + def test_missing_signal_defaults_to_dark(self): + assert detect_light_background({}) is False + + def test_malformed_signal_defaults_to_dark(self): + assert detect_light_background({"COLORFGBG": "not-a-number"}) is False + + @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE"]) + def test_flag_value_variants(self, value): + assert detect_light_background({"PYRAG_LIGHT": value}) is True + + +class TestThemeStyles: + """两套样式的颜色必须按主题切换(红/绿语义:亮色给深变体)。""" + + def test_prompt_style_differs_between_themes(self): + dark = str(prompt_style_for_theme(light=False).style_rules) + light = str(prompt_style_for_theme(light=True).style_rules) + assert dark != light + assert "#87d7af" in dark # 暗色沿用原浅色 + assert "#0e7490" in light # 亮色深变体 + + def test_questionary_pointer_differs_between_themes(self): + dark = str(questionary_style_for_theme(light=False).style_rules) + light = str(questionary_style_for_theme(light=True).style_rules) + assert dark != light + assert "ansiyellow" in dark + assert "#a16207" in light + + def test_styles_parse_with_prompt_toolkit(self): + # from_dict 已在构造时验证语法;这里再断言产物可用(不抛)。 + assert prompt_style_for_theme(light=False) is not None + assert questionary_style_for_theme(light=True) is not None