"""配置热加载测试:改 .env / llm_models.yaml 后无需重启即生效。 覆盖三层: - configs/loader.py YAML 场景((mtime, size) 失效缓存) - configs/runtime_env .env(热更新 / 删除语义 / 外部显式覆盖优先) - scripts/run_scheduler 定时任务热同步(改 SCHEDULE_TIMES 等) """ from __future__ import annotations import os from pathlib import Path import pytest from configs import loader, runtime_env @pytest.fixture(autouse=True) def _clean_caches() -> None: """每个用例前后都清干净,避免托管的环境变量污染其它测试。""" loader.clear_cache() runtime_env.reset_cache() yield loader.clear_cache() runtime_env.reset_cache() def _use_env_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, content: str) -> Path: """把 runtime_env 指向临时 .env,返回其路径。""" env = tmp_path / ".env" env.write_text(content, encoding="utf-8") monkeypatch.setenv(runtime_env.ENV_FILE_OVERRIDE, str(env)) runtime_env.reset_cache() return env # --------------------------------------------------------------------------- # # YAML 场景热加载 # --------------------------------------------------------------------------- # def test_yaml_scene_hot_reload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """改 llm_models.yaml 后,下一次 load_scene_config 立即读到新值。""" cfg = tmp_path / "llm_models.yaml" cfg.write_text( "scenes:\n daily_report:\n provider: qwen\n model: qwen3.6-flash\n", encoding="utf-8", ) monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(cfg)) assert loader.load_scene_config("daily_report")["model"] == "qwen3.6-flash" # 内容长度不同,保证 (mtime, size) 签名一定变化 cfg.write_text( "scenes:\n daily_report:\n provider: qwen\n model: deepseek-v4.1-flash\n", encoding="utf-8", ) # 不重启、不 clear_cache 也应生效 assert loader.load_scene_config("daily_report")["model"] == "deepseek-v4.1-flash" def test_yaml_scene_removed_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """场景被删掉后返回空 dict(由调用方回退 .env)。""" cfg = tmp_path / "llm_models.yaml" cfg.write_text("scenes:\n stock_report:\n model: x\n", encoding="utf-8") monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(cfg)) assert loader.load_scene_config("stock_report")["model"] == "x" cfg.write_text("scenes: {}\n", encoding="utf-8") assert loader.load_scene_config("stock_report") == {} def test_yaml_missing_file_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(tmp_path / "nope.yaml")) assert loader.load_scene_config("daily_report") == {} assert loader.load_defaults() == {} # --------------------------------------------------------------------------- # # .env 热加载 # --------------------------------------------------------------------------- # def test_env_hot_reload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """改 .env 后 env_get 立即读到新值。""" env = _use_env_file(tmp_path, monkeypatch, "HOT_RELOAD_KEY=one\n") monkeypatch.delenv("HOT_RELOAD_KEY", raising=False) assert runtime_env.env_get("HOT_RELOAD_KEY") == "one" env.write_text("HOT_RELOAD_KEY=two-longer-value\n", encoding="utf-8") assert runtime_env.env_get("HOT_RELOAD_KEY") == "two-longer-value" def test_env_removed_key_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """从 .env 删掉的托管键要同步从 os.environ 移除。""" env = _use_env_file(tmp_path, monkeypatch, "HOT_REMOVE_KEY=abc\n") monkeypatch.delenv("HOT_REMOVE_KEY", raising=False) assert runtime_env.env_get("HOT_REMOVE_KEY") == "abc" assert os.environ["HOT_REMOVE_KEY"] == "abc" env.write_text("OTHER_KEY=1\n", encoding="utf-8") assert runtime_env.env_get("HOT_REMOVE_KEY") is None assert "HOT_REMOVE_KEY" not in os.environ def test_env_empty_value_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _use_env_file(tmp_path, monkeypatch, "HOT_EMPTY_KEY=\n") monkeypatch.delenv("HOT_EMPTY_KEY", raising=False) assert runtime_env.env_get("HOT_EMPTY_KEY", "fallback") == "fallback" def test_explicit_process_env_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """进程环境显式设置且与文件不同 → 不被 .env 覆盖(一次性覆盖语义)。""" env = _use_env_file(tmp_path, monkeypatch, "HOT_OVERRIDE_KEY=from-file\n") monkeypatch.setenv("HOT_OVERRIDE_KEY", "from-shell") assert runtime_env.env_get("HOT_OVERRIDE_KEY") == "from-shell" env.write_text("HOT_OVERRIDE_KEY=from-file-changed\n", encoding="utf-8") assert runtime_env.env_get("HOT_OVERRIDE_KEY") == "from-shell" def test_llm_config_follows_env_hot_reload( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """端到端:改 .env 里的 QWEN_MODEL,load_llm_config 立即用新模型。""" from llm.client import load_llm_config contents = ( "LLM_PROVIDER=qwen\n" "QWEN_API_KEY=sk-test\n" "QWEN_BASE_URL=https://token-plan.example/compatible-mode/v1\n" "QWEN_MODEL=qwen3.6-flash\n" ) env = _use_env_file(tmp_path, monkeypatch, contents) for key in ("LLM_PROVIDER", "QWEN_API_KEY", "QWEN_BASE_URL", "QWEN_MODEL"): monkeypatch.delenv(key, raising=False) cfg = load_llm_config(scene="") assert cfg.provider == "qwen" assert cfg.model == "qwen3.6-flash" env.write_text(contents.replace("qwen3.6-flash", "deepseek-v4.1-flash"), encoding="utf-8") assert load_llm_config(scene="").model == "deepseek-v4.1-flash" # --------------------------------------------------------------------------- # # 调度任务热同步 # --------------------------------------------------------------------------- # def test_desired_jobs_tracks_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """SCHEDULE_TIMES / CNINFO_SCHEDULE_TIME / STOCK_REPORT_TIME 改完立即反映。""" from scripts.run_scheduler import _desired_jobs _use_env_file( tmp_path, monkeypatch, "SCHEDULE_TIMES=09:15\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=\n", ) jobs = _desired_jobs() assert set(jobs) == {"pipeline_0915", "pipeline_cninfo"} # 当天唯一次数 → 附带日报步骤 assert jobs["pipeline_0915"]["steps"][-1] == "report" assert jobs["pipeline_cninfo"]["hour"] == 5 assert jobs["pipeline_cninfo"]["minute"] == 45 def test_sync_jobs_adds_and_removes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """_sync_jobs 只按需增删:加时间点、启停个股日报、幂等。""" from apscheduler.schedulers.background import BackgroundScheduler import scripts.run_scheduler as rs env = _use_env_file( tmp_path, monkeypatch, "SCHEDULE_TIMES=09:15\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=\n", ) rs._jobs_sig = None sched = BackgroundScheduler() assert rs._sync_jobs(sched) is True assert {j.id for j in sched.get_jobs()} == {"pipeline_0915", "pipeline_cninfo"} # 新增一个时间点 + 启用个股日报 env.write_text( "SCHEDULE_TIMES=09:15,16:40\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=07:30\n", encoding="utf-8", ) assert rs._sync_jobs(sched) is True ids = {j.id for j in sched.get_jobs()} assert ids == {"pipeline_0915", "pipeline_1640", "pipeline_cninfo", "stock_report"} # 配置未变 → 不重复变更 assert rs._sync_jobs(sched) is False def test_parse_hhmm_falls_back_on_bad_input() -> None: from scripts.run_scheduler import _parse_hhmm assert _parse_hhmm("06:30", (0, 0)) == (6, 30) assert _parse_hhmm("99:99", (6, 30)) == (6, 30) assert _parse_hhmm("garbage", (7, 0)) == (7, 0) # --------------------------------------------------------------------------- # # 其它模块复用热读取 # --------------------------------------------------------------------------- # def test_reporter_days_back_is_lazy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """日报的 STOCK_REPORT_DAYS 走懒读取,不再冻结在模块常量里。""" from scheduler import reporter _use_env_file(tmp_path, monkeypatch, "STOCK_REPORT_DAYS=15\n") monkeypatch.delenv("STOCK_REPORT_DAYS", raising=False) assert reporter._cninfo_days_back() == 15