"""运行期配置热加载:改 ``.env`` 后无需重启进程即生效。 为什么需要它 ------------ 常驻进程(``scripts/run_scheduler.py``、``mcp_server``)启动时把 ``.env`` 读进 ``os.environ``,之后再改 ``.env`` 不会生效——子进程虽然会 ``load_dotenv()``,但 它继承的是父进程那份旧环境,而 python-dotenv 默认不覆盖已存在的键,于是 "改了配置却没反应"。 做法 ---- - :func:`ensure_env_loaded`:先比对 ``.env`` 的 ``(mtime_ns, size)``。文件没变时 只做一次 ``stat``;变了才重新解析并同步到 ``os.environ``。 - 删除语义:上一轮由 ``.env`` 带入、这一轮已从文件里删掉的键会被清除, 保证"文件即事实源",而不是只能加不能减。 - :func:`env_get`:先热加载再读取,供各模块统一取配置(空字符串视为未设置)。 - :func:`start_env_watcher`:守护线程周期性刷新,照顾那些仍直接读 ``os.environ`` 的历史代码路径。 优先级(从高到低) ------------------ 显式参数 / CLI > configs/llm_models.yaml 场景 > 进程环境(shell / systemd) > .env 文件 > 内置默认值 其中「进程环境」与「.env」的关系是: - 进程环境里**显式设置且与文件不同**的键优先,热加载不会覆盖它 (例如 ``LLM_PROVIDER=qwen python -m a_share_cli`` 这种一次性覆盖); - 其余键由本模块托管,跟随 ``.env`` 文件变化即时更新; - 从 ``.env`` 里删掉的托管键,会同步从进程环境移除。 测试或部署可用 ``A_SHARE_ENV_FILE`` 指定其它 ``.env`` 路径。 """ from __future__ import annotations import os import threading import time from pathlib import Path from dotenv import dotenv_values from loguru import logger #: 指定替代的 .env 路径(测试 / 多环境部署用) ENV_FILE_OVERRIDE = "A_SHARE_ENV_FILE" #: watcher 轮询间隔(秒) WATCH_INTERVAL_SEC = 2.0 _lock = threading.Lock() _signature: tuple[int, int] | None = None #: 由本模块写入 os.environ 的键 -> 写入值;用于识别"外部显式覆盖" _managed: dict[str, str] = {} def dotenv_path() -> Path: """返回当前使用的 ``.env`` 路径。""" override = os.environ.get(ENV_FILE_OVERRIDE) if override: return Path(override).expanduser() return Path(__file__).resolve().parents[1] / ".env" def file_signature(path: Path) -> tuple[int, int] | None: """返回 ``(mtime_ns, size)``;文件不存在时返回 None。""" try: st = path.stat() except OSError: return None return (st.st_mtime_ns, st.st_size) def _release_all() -> None: """撤下所有仍由本模块托管的键(.env 消失时)。""" for key, managed_value in list(_managed.items()): if os.environ.get(key) == managed_value: os.environ.pop(key, None) del _managed[key] def _apply(values: dict[str, str]) -> None: """把文件值同步到 ``os.environ``,尊重外部显式覆盖。""" # 1) 已从文件移除的托管键 → 同步删除 for key in list(_managed): if key in values: continue if os.environ.get(key) == _managed[key]: os.environ.pop(key, None) del _managed[key] # 2) 应用文件中的键 for key, value in values.items(): current = os.environ.get(key) if current is None or current == value: # 未设置,或与文件一致 → 交给文件托管(后续可热更新) os.environ[key] = value _managed[key] = value elif _managed.get(key) == current: # 当前值正是本模块上一轮写入的 → 跟随文件热更新 os.environ[key] = value _managed[key] = value else: # 进程环境里显式设置且与文件不同 → 外部优先,不接管 _managed.pop(key, None) def ensure_env_loaded(force: bool = False) -> bool: """确保 ``os.environ`` 与 ``.env`` 文件一致。 Args: force: 忽略签名缓存,强制重新解析(首次加载 / 测试用)。 Returns: 本次是否真的重新加载了文件。 """ global _signature path = dotenv_path() sig = file_signature(path) with _lock: if not force and sig == _signature: return False if sig is None: removed = len(_managed) _release_all() _signature = None if removed: logger.warning("{} 不可读,已回退 {} 个环境变量", path, removed) return True values = {k: v for k, v in dotenv_values(path).items() if v is not None} _apply(values) _signature = sig logger.debug("已加载/热更新 {}({} 项)", path, len(values)) return True def env_get(key: str, default: str | None = None) -> str | None: """读取配置项(读取前自动热加载 ``.env``);空字符串视为未设置。""" ensure_env_loaded() value = os.environ.get(key) if value is None or value.strip() == "": return default return value.strip() def env_raw(key: str, default: str | None = None) -> str | None: """读取配置项原始值(读取前自动热加载 ``.env``)。 与 :func:`env_get` 的区别:**不把空字符串当作未设置**。 用于"显式留空表示禁用"这类开关,例如 ``STOCK_REPORT_TIME=``。 """ ensure_env_loaded() value = os.environ.get(key) if value is None: return default return value.strip() def start_env_watcher(interval: float = WATCH_INTERVAL_SEC) -> threading.Thread: """启动守护线程:周期性检查 ``.env``,变了就热更新 ``os.environ``。 只对**常驻进程**有意义;短命的一次性脚本按需读取即可。 """ def _loop() -> None: while True: try: ensure_env_loaded() except Exception: # noqa: BLE001 - 热加载失败不应拖垮主进程 logger.exception("热加载 .env 失败") time.sleep(interval) thread = threading.Thread(target=_loop, name="env-watcher", daemon=True) thread.start() logger.info("已启动 .env 热加载监听(每 {:.0f}s 检查一次)", interval) return thread def reset_cache() -> None: """仅供测试:撤下托管键并清空签名缓存。""" global _signature with _lock: _release_all() _signature = None