Files
2026-07-13 12:34:57 +08:00

599 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
配置系统特征测试(characterization tests
固化 myboot.core.config 的「当前实际行为」,作为后续重构的兼容性闸门。
所有断言以代码现状为准(已用探针脚本逐项验证),而非文档或「理想行为」。
已知可疑现状(详见各测试注释,重构时务必先确认再改):
1. ``create_settings()`` 把 ``default_settings={...}`` 作为普通 kwarg 传给
Dynaconf。Dynaconf 没有这个选项,于是它变成了一个名为
``DEFAULT_SETTINGS`` 的普通配置项——内置默认值并 **不会** 出现在
``app.name`` / ``server.port`` 等路径上(与 docs/configuration.md 2.3 节相悖)。
2. 由 1 推论:在没有任何 YAML 声明对应键时,``SERVER__PORT`` 等环境变量
会被 ``ignore_unknown_envvars=True`` 静默忽略(文档 5.6 声称内置默认键
可直接用 .env 覆盖,实际不行)。
3. 同目录同时存在 ``config.yaml`` 与 ``config.yml`` 时,``.yml`` 后加载,
**覆盖** ``.yaml`` 的同名键。
4. ``merge_enabled=True`` 下,多文件中同路径的 list 会拼接(可能重复),
嵌套 dict 深合并;``dynaconf_merge: false`` 则整块替换(issue #13)。
5. ``env_parse_values=True`` 下,``"1"`` 解析为 int 1 而非 bool True。
6. 环境变量提供的 JSON 列表会 **整体替换** YAML 中的列表(不拼接)。
注意:``_find_project_root()`` 以 config.py 所在包向上找 pyproject.toml
与进程工作目录无关(仓库内运行时恒为仓库根)。因此仅靠
``monkeypatch.chdir(tmp_path)`` 无法隔离根 config.yaml 查找,必须同时
monkeypatch ``_find_project_root``。
"""
import hashlib
import os
import tempfile
import textwrap
import uuid
import pytest
import myboot.core.config as config_module
from myboot.core.config import (
_get_config_files,
_is_url,
create_settings,
get_config,
get_config_bool,
get_config_int,
get_config_str,
get_settings,
reload_config,
)
@pytest.fixture(autouse=True)
def isolate_config(tmp_path, monkeypatch):
"""每个测试独立的「项目根」+ 干净的全局单例 / CONFIG_FILE。
- monkeypatch _find_project_root:见模块 docstring,仅 chdir 不够。
- chdir 仍然保留,防止任何依赖 cwd 的退路逻辑读到真实仓库。
- 前后都清空模块级单例 _settings,避免测试间污染。
"""
monkeypatch.setattr(config_module, "_find_project_root", lambda: str(tmp_path))
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CONFIG_FILE", raising=False)
reload_config()
yield
reload_config()
def write_yaml(path, content):
"""写入 YAML 文件(自动 dedent / 建父目录),返回 str 路径"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content), encoding="utf-8")
return str(path)
# ---------------------------------------------------------------------------
# 1. 多源配置文件优先级
# ---------------------------------------------------------------------------
class TestConfigFilePriority:
def test_root_config_yaml_loaded(self, tmp_path):
write_yaml(tmp_path / "config.yaml", """\
app:
name: root-app
""")
settings = create_settings()
assert settings.get("app.name") == "root-app"
def test_conf_dir_overrides_root(self, tmp_path):
write_yaml(tmp_path / "config.yaml", """\
app:
name: root-app
server:
port: 1111
only_root: 1
""")
write_yaml(tmp_path / "conf" / "config.yaml", """\
app:
name: conf-app
server:
port: 2222
""")
settings = create_settings()
# conf/config.yaml 后加载,覆盖根目录同名标量
assert settings.get("app.name") == "conf-app"
assert settings.get("server.port") == 2222
# 仅根目录出现的键仍然保留(merge_enabled=True
assert settings.get("only_root") == 1
def test_param_config_file_overrides_conf(self, tmp_path, tmp_config_file):
write_yaml(tmp_path / "conf" / "config.yaml", """\
app:
name: conf-app
only_conf: 2
""")
param = tmp_config_file(
"""\
app:
name: param-app
""",
filename="param.yaml",
)
settings = create_settings(param)
assert settings.get("app.name") == "param-app"
assert settings.get("only_conf") == 2
def test_config_file_env_var_overrides_param(
self, tmp_path, tmp_config_file, monkeypatch
):
param = tmp_config_file(
"""\
app:
name: param-app
only_param: 3
""",
filename="param.yaml",
)
env_file = tmp_config_file(
"""\
app:
name: envfile-app
""",
filename="envfile.yaml",
)
monkeypatch.setenv("CONFIG_FILE", env_file)
settings = create_settings(param)
# CONFIG_FILE 指定的文件最后加载,优先级最高(文件来源中)
assert settings.get("app.name") == "envfile-app"
assert settings.get("only_param") == 3
def test_get_config_files_order(self, tmp_path, monkeypatch):
"""固化 _get_config_files 返回的加载顺序:根 → conf → 参数 → CONFIG_FILE"""
root = write_yaml(tmp_path / "config.yaml", "a: 1\n")
conf = write_yaml(tmp_path / "conf" / "config.yaml", "a: 2\n")
param = write_yaml(tmp_path / "param.yaml", "a: 3\n")
envf = write_yaml(tmp_path / "envfile.yaml", "a: 4\n")
monkeypatch.setenv("CONFIG_FILE", envf)
assert _get_config_files(param) == [root, conf, param, envf]
def test_nonexistent_param_file_silently_dropped(self, tmp_path):
write_yaml(tmp_path / "config.yaml", "app:\n name: root-app\n")
# 不存在的文件路径不报错,静默跳过
settings = create_settings(str(tmp_path / "no-such-file.yaml"))
assert settings.get("app.name") == "root-app"
def test_yml_overrides_yaml_in_same_dir(self, tmp_path):
"""可疑现状 #3config.yml 排在 config.yaml 之后加载,.yml 覆盖 .yaml。
直觉上 .yaml 是「主」扩展名,但实际 .yml 同名键胜出。
"""
write_yaml(tmp_path / "config.yaml", "app:\n name: from-yaml\n")
write_yaml(tmp_path / "config.yml", "app:\n name: from-yml\n")
settings = create_settings()
assert settings.get("app.name") == "from-yml"
# ---------------------------------------------------------------------------
# 2. 内置默认值(0.2.0 修复:默认值以大写 kwargs 传入 Dynaconf
# ---------------------------------------------------------------------------
class TestDefaultSettings:
def test_builtin_defaults_exposed_at_documented_paths(self):
"""0.2.0 修复:无任何 YAML 时内置默认值在文档路径上可取到。
旧版把 default_settings={...} 当 kwarg 传给 Dynaconf(无此参数),
默认值整体失效;现以大写 kwargsAPP=/SERVER=...)注册为默认配置项。
"""
settings = create_settings()
assert settings.get("app.name") == "MyBoot App"
assert settings.get("server.port") == 8000
assert settings.get("logging.level") == "INFO"
assert settings.get("scheduler.enabled") is True
assert settings.get("scheduler.max_workers") == 10
def test_no_stray_default_settings_key(self):
"""修复后不再产生名为 DEFAULT_SETTINGS 的杂散配置项"""
settings = create_settings()
assert "DEFAULT_SETTINGS" not in settings
def test_yaml_partial_override_deep_merges_with_defaults(self, tmp_path):
"""YAML 只覆盖 server.port 时,server 其余默认键保留(深合并)"""
write_yaml(tmp_path / "config.yaml", "server:\n port: 9000\n")
settings = create_settings()
assert settings.get("server.port") == 9000
assert settings.get("server.host") == "0.0.0.0"
assert settings.get("server.workers") == 1
# ---------------------------------------------------------------------------
# 3. 环境变量 `__` 分隔符覆盖
# ---------------------------------------------------------------------------
class TestEnvVarOverride:
def test_double_underscore_overrides_declared_key(self, tmp_path, monkeypatch):
# envvar_prefix=False:变量名即配置路径大写形式,无前缀
write_yaml(tmp_path / "config.yaml", """\
app:
name: yaml-app
""")
monkeypatch.setenv("APP__NAME", "env-app")
settings = create_settings()
assert settings.get("app.name") == "env-app"
def test_env_var_beats_config_file_env_source(
self, tmp_path, tmp_config_file, monkeypatch
):
env_file = tmp_config_file(
"""\
app:
name: from-config-file
""",
filename="cf.yaml",
)
monkeypatch.setenv("CONFIG_FILE", env_file)
monkeypatch.setenv("APP__NAME", "from-envvar")
settings = create_settings()
# 环境变量键 > CONFIG_FILE 文件
assert settings.get("app.name") == "from-envvar"
def test_undeclared_env_key_silently_ignored(self, tmp_path, monkeypatch):
# ignore_unknown_envvars=TrueYAML 中没有 database 段时被静默忽略
write_yaml(tmp_path / "config.yaml", "app:\n name: yaml-app\n")
monkeypatch.setenv("DATABASE__URL", "postgresql://ignored")
settings = create_settings()
assert settings.get("database.url") is None
def test_undeclared_leaf_under_declared_parent_also_ignored(
self, tmp_path, monkeypatch
):
# server 段存在但 server.custom_flag 既未在 YAML 也未在内置默认值中
# 声明 → SERVER__CUSTOM_FLAG 被 ignore_unknown_envvars 忽略
# (注意 server.reload 已是内置默认键,不能再用作"未声明"示例)
write_yaml(tmp_path / "config.yaml", """\
server:
port: 8000
""")
monkeypatch.setenv("SERVER__CUSTOM_FLAG", "true")
settings = create_settings()
assert settings.get("server.custom_flag") is None
def test_env_var_overrides_builtin_defaults_without_yaml(self, monkeypatch):
"""0.2.0 修复:内置默认键生效后,无 YAML 时也可用环境变量覆盖
(与 docs/configuration.md 5.6 节一致)"""
monkeypatch.setenv("SERVER__PORT", "7777")
monkeypatch.setenv("APP__NAME", "env-only")
settings = create_settings()
assert settings.get("server.port") == 7777
assert settings.get("app.name") == "env-only"
# ---------------------------------------------------------------------------
# 4. 类型自动转换(env_parse_values=True
# ---------------------------------------------------------------------------
class TestEnvVarTypeConversion:
@pytest.fixture(autouse=True)
def base_yaml(self, tmp_path):
write_yaml(tmp_path / "config.yaml", """\
app:
debug: false
server:
port: 8000
reload: false
cors:
allow_origins: ["*"]
""")
def test_numeric_string_to_int(self, monkeypatch):
monkeypatch.setenv("SERVER__PORT", "9090")
settings = create_settings()
value = settings.get("server.port")
assert value == 9090
assert isinstance(value, int)
def test_true_false_strings_to_bool(self, monkeypatch):
monkeypatch.setenv("SERVER__RELOAD", "true")
settings = create_settings()
assert settings.get("server.reload") is True
monkeypatch.setenv("SERVER__RELOAD", "false")
settings = create_settings()
assert settings.get("server.reload") is False
def test_string_one_parses_as_int_not_bool(self, monkeypatch):
"""可疑现状 #5"1" 被解析为 int 1,不是 bool True。
依赖 `settings.get("app.debug") is True` 的调用方会受影响;
get_config_bool 仍能正确转换(见 TestHelperFunctions)。
"""
monkeypatch.setenv("APP__DEBUG", "1")
settings = create_settings()
value = settings.get("app.debug")
assert value == 1
assert not isinstance(value, bool)
assert isinstance(value, int)
def test_json_list_string_parsed_and_replaces_yaml_list(self, monkeypatch):
"""可疑现状 #6env 提供的 JSON 列表整体替换 YAML 列表,
不与原列表 ["*"] 拼接(与多文件加载时的列表拼接行为不同)。
"""
monkeypatch.setenv(
"SERVER__CORS__ALLOW_ORIGINS", '["http://a", "http://b"]'
)
settings = create_settings()
assert list(settings.get("server.cors.allow_origins")) == [
"http://a",
"http://b",
]
# ---------------------------------------------------------------------------
# 5. 合并行为与 dynaconf_merge: falseissue #13 回归)
# ---------------------------------------------------------------------------
class TestMergeBehavior:
def _write_base_cors(self, tmp_path):
write_yaml(tmp_path / "config.yaml", """\
server:
cors:
allow_origins: ["*"]
allow_methods: ["*"]
allow_headers: ["*"]
""")
def test_nested_dict_deep_merge_keeps_sibling_keys(self, tmp_path):
# merge_enabled=True:后加载文件只写部分子键时,旧子键保留
self._write_base_cors(tmp_path)
write_yaml(tmp_path / "conf" / "config.yaml", """\
server:
cors:
allow_origins: ["http://x"]
""")
settings = create_settings()
cors = settings.get("server.cors")
assert list(cors["allow_methods"]) == ["*"]
assert list(cors["allow_headers"]) == ["*"]
def test_list_values_concatenate_across_files(self, tmp_path):
"""可疑现状 #4:同路径列表跨文件「拼接」而非替换,可能产生意外元素。
基底 ["*"] + 覆盖文件 ["http://x"] → ["*", "http://x"]
通配符 "*" 仍然留在 CORS origins 里。
"""
self._write_base_cors(tmp_path)
write_yaml(tmp_path / "conf" / "config.yaml", """\
server:
cors:
allow_origins: ["http://x"]
""")
settings = create_settings()
assert list(settings.get("server.cors.allow_origins")) == ["*", "http://x"]
def test_dynaconf_merge_false_replaces_whole_dict(self, tmp_path):
"""issue #13 回归:dynaconf_merge: false 时嵌套 dict 整块替换,
先前的 allow_methods / allow_headers 不再保留。
"""
self._write_base_cors(tmp_path)
write_yaml(tmp_path / "conf" / "config.yaml", """\
server:
cors:
allow_origins: ["http://x"]
dynaconf_merge: false
""")
settings = create_settings()
cors = settings.get("server.cors")
assert cors.to_dict() == {"allow_origins": ["http://x"]}
def test_dynaconf_merge_marker_not_exposed_as_config_key(self, tmp_path):
self._write_base_cors(tmp_path)
write_yaml(tmp_path / "conf" / "config.yaml", """\
server:
cors:
allow_origins: ["http://x"]
dynaconf_merge: false
""")
settings = create_settings()
# 合并控制元数据不会作为业务键出现
assert settings.get("server.cors.dynaconf_merge") is None
# ---------------------------------------------------------------------------
# 6. 缺失键默认值 / 便捷函数 / 大小写
# ---------------------------------------------------------------------------
class TestHelperFunctions:
@pytest.fixture(autouse=True)
def base_yaml(self, tmp_path):
write_yaml(tmp_path / "config.yaml", """\
app:
name: helper-app
server:
port: 8000
flags:
str_yes: "yes"
str_other: "definitely"
items:
- a
- b
""")
reload_config() # 便捷函数走全局单例,确保读到本测试的 YAML
def test_get_missing_key_returns_default(self):
assert get_config("no.such.key", "fallback") == "fallback"
def test_get_missing_key_default_none(self):
assert get_config("no.such.key") is None
def test_get_config_str_converts(self):
assert get_config_str("server.port") == "8000"
assert get_config_str("no.such.key", "dft") == "dft"
def test_get_config_int_converts_and_falls_back(self):
assert get_config_int("server.port") == 8000
# 列表无法转 int → 返回 default(当前实现吞掉 TypeError
assert get_config_int("items", 42) == 42
assert get_config_int("no.such.key", 7) == 7
def test_get_config_bool_string_semantics(self):
# 字符串仅 'true'/'1'/'yes'/'on'(不区分大小写)为真
assert get_config_bool("flags.str_yes") is True
assert get_config_bool("flags.str_other") is False
assert get_config_bool("no.such.key", True) is True
def test_get_is_case_insensitive(self):
settings = get_settings()
assert settings.get("APP.NAME") == "helper-app"
assert settings.get("app.name") == "helper-app"
class TestSingletonBehavior:
def test_get_settings_ignores_config_file_after_first_call(
self, tmp_path, tmp_config_file
):
write_yaml(tmp_path / "config.yaml", "app:\n name: first\n")
other = tmp_config_file("app:\n name: second\n", filename="other.yaml")
first = get_settings()
assert first.get("app.name") == "first"
# 单例已固定,后续传入不同 config_file 不生效
second = get_settings(other)
assert second is first
assert second.get("app.name") == "first"
def test_reload_config_resets_singleton(self, tmp_path, tmp_config_file):
write_yaml(tmp_path / "config.yaml", "app:\n name: first\n")
other = tmp_config_file("app:\n name: second\n", filename="other.yaml")
first = get_settings()
reload_config()
second = get_settings(other)
assert second is not first
assert second.get("app.name") == "second"
# ---------------------------------------------------------------------------
# 7. 远程配置(HTTP URLmock requests
# ---------------------------------------------------------------------------
def _cache_path_for(url):
cache_dir = os.path.join(tempfile.gettempdir(), "myboot_config_cache")
return os.path.join(cache_dir, hashlib.md5(url.encode()).hexdigest() + ".yaml")
@pytest.fixture
def remote_url():
"""每个测试唯一的 URL,避免共享缓存目录(系统临时目录)相互污染"""
url = f"https://config.example.invalid/{uuid.uuid4().hex}.yaml"
yield url
cache = _cache_path_for(url)
if os.path.exists(cache):
os.remove(cache)
class TestDotenvAutoLoad:
"""0.2.x F1:项目根 .env 自动加载(Dynaconf load_dotenv"""
@pytest.fixture(autouse=True)
def isolate_environ(self):
# load_dotenv 会写 os.environ;快照-恢复整个 environ 避免污染后续测试
# (不能用 monkeypatch.setattr 替换 os.environ —— dotenv/dynaconf
# 内部持有原始 _Environ 对象引用,替换属性不生效)
saved = dict(os.environ)
yield
os.environ.clear()
os.environ.update(saved)
def test_dotenv_loaded_from_project_root(self, tmp_path):
(tmp_path / ".env").write_text("APP__NAME=dotenv-app\n", encoding="utf-8")
settings = create_settings()
assert settings.get("app.name") == "dotenv-app"
def test_real_env_var_beats_dotenv(self, tmp_path, monkeypatch):
# dotenv_override=False:真实环境变量优先于 .env
(tmp_path / ".env").write_text("APP__NAME=dotenv-app\n", encoding="utf-8")
monkeypatch.setenv("APP__NAME", "real-env")
settings = create_settings()
assert settings.get("app.name") == "real-env"
def test_no_dotenv_file_is_fine(self, tmp_path):
settings = create_settings()
assert settings.get("app.name") == "MyBoot App"
class TestRemoteConfig:
def test_is_url_detection(self):
assert _is_url("http://example.com/c.yaml")
assert _is_url("https://example.com/c.yaml")
assert not _is_url("/etc/myboot/config.yaml")
assert not _is_url("ftp://example.com/c.yaml")
# 空字符串走 `path and ...` 短路,返回原值(falsy),不是 False
assert not _is_url("")
def test_remote_config_downloaded_cached_and_loaded(
self, monkeypatch, mocker, remote_url
):
response = mocker.Mock()
response.text = "app:\n name: remote-app\nremote_only: 42\n"
response.raise_for_status = mocker.Mock()
mock_get = mocker.patch.object(
config_module.requests, "get", return_value=response
)
monkeypatch.setenv("CONFIG_FILE", remote_url)
settings = create_settings()
mock_get.assert_called_once_with(remote_url, timeout=30)
# 下载内容写入系统临时目录缓存(md5(url).yaml
cache = _cache_path_for(remote_url)
assert os.path.exists(cache)
assert "remote-app" in open(cache, encoding="utf-8").read()
# 远程配置作为 CONFIG_FILE 来源参与合并
assert settings.get("app.name") == "remote-app"
assert settings.get("remote_only") == 42
def test_download_failure_falls_back_to_existing_cache(
self, monkeypatch, mocker, remote_url
):
cache = _cache_path_for(remote_url)
os.makedirs(os.path.dirname(cache), exist_ok=True)
with open(cache, "w", encoding="utf-8") as f:
f.write("app:\n name: cached-app\n")
mocker.patch.object(
config_module.requests,
"get",
side_effect=ConnectionError("network down"),
)
monkeypatch.setenv("CONFIG_FILE", remote_url)
settings = create_settings()
assert settings.get("app.name") == "cached-app"
def test_download_failure_without_cache_raises(
self, monkeypatch, mocker, remote_url
):
# 无缓存且下载失败 → 原始异常向上抛出(create_settings 直接失败)
mocker.patch.object(
config_module.requests,
"get",
side_effect=ValueError("network down"),
)
monkeypatch.setenv("CONFIG_FILE", remote_url)
with pytest.raises(ValueError, match="network down"):
create_settings()
def test_remote_param_config_file_also_supported(self, mocker, remote_url):
# config_file 参数同样支持 URL(不只 CONFIG_FILE 环境变量)
response = mocker.Mock()
response.text = "app:\n name: remote-param\n"
response.raise_for_status = mocker.Mock()
mocker.patch.object(config_module.requests, "get", return_value=response)
settings = create_settings(remote_url)
assert settings.get("app.name") == "remote-param"